See proposal.md for the motivation. PR #32 landed format 3.0.0 beside draft IR v4. The parts this change
+touches are:
+
+
Module
State before this change
+
+
morphir.ir.format_version
V3_INTERVAL is [3.0.0,3.1.0); a 3.1.0 document fails with unsupported_format_version_minor and a pointer to issue #28.
+
morphir.ir.v3.distribution
Distribution = Library.
+
morphir.ir.v3.codec
decode_distribution refuses every tag but Library; encode_versioned_distribution always writes 3.
+
morphir.ir.migrate
v3_to_v4 takes a Library; v4_to_v3 refuses Specs and Application with unsupported_v4_downgrade.
+
morphir_tools.cli
ir inspect and ir convert assume a v3 file holds a Library.
+
+
+
The v4 model and codec (morphir.ir.v4) are being realigned in parallel under
+morphir-ukvn.8.14. This change reads them but does not edit them, and keeps its edits to
+migrate.py on the Specs path.
+
+
Sources
+
+
Source
Commit
What it settles
+
+
finos/morphir decision record 0018, kb/bundles/morphir/morphir-ir/decisions/0018-ir-3-1-adds-specs-and-v3-document-trees.md
efa36060
Lowest-version writers, specs_before_3_1, the reference table [3.0.0,3.2.0),[4.0.0,4.1.0).
Canonical JSON of a v3 Specs; "3.1.0" supported; "3.2.0" refused as a minor.
+
finos/morphir-rust "feat(ir)!: IR 3.1.0 — a v3 Specs distribution (#256)": crates/morphir-core/src/ir/classic/distribution.rs, package.rs, migration/classic_to_v4.rs, tests/classic_specs_distribution.rs, morphir-common/tests/classic_specs_transport.rs
cd9f7fb
Reference behaviour: strict Specs modules, the version check, the writer rule, the v3 to v4 migration.
+
+
+
+
Goals and non-goals
+
Goals: read every 3.0.x and 3.1.x single-file JSON document; read and write a v3
+Specs; migrate Specs both ways; keep every 3.0.0 output byte-identical to
+before.
+
Non-goals:
+
+
v3 JSON and YAML document trees (see SCOPE-1; follow-up morphir-ukvn.8.17).
+
Any change to the v4 model or codec.
+
Formats 1 and 2 (morphir-ukvn.8.12).
+
+
+
Decisions
+
+
FV-1. Raise the table; keep one reader
+
V3_INTERVAL becomes [3.0.0,3.2.0), so SUPPORT_TABLE and V3_TABLE
+follow. A new constant V3_1 = FormatVersion(3, 1, 0) names the release that introduced Specs,
+so no module compares raw tuples. The human description of a table lists each minor family it covers
+(3.0.x, 3.1.x and draft 4.0.x), because the old "3.0.x" label would now be wrong. The issue
+#28 pointer in the minor diagnostic goes away; a 3.2.0 refusal names the table instead.
+
Alternative: a separate reader for 3.1. Rejected: the format-version contract has one reader per table, and the
+patch promise (decision 0016) says a ceiling on a minor boundary is all that changes.
Field names follow Library and the v4 SpecsDistribution, so migration code reads the same in
+both directions. There is no version field: the model holds content, and the writer derives the version from it
+(V3-4). This mirrors Rust DistributionBody::Specs, minus Rust's stored major.
+
+
V3-2. The version check runs before the body decodes
+
decode_versioned_distribution reads the root with V3_TABLE, then reads the distribution
+tag. If the tag is Specs and the declared release is below V3_1, it raises
+specs_before_3_1 at formatVersion, before it decodes the body. Rust decodes first and checks
+after; the kit pins only the code, not the order. Checking first means a Specs mislabelled as
+3 reports the root cause, not a body error. The check uses the normalized release, so the permissive
+spellings 3, "3" and "3.0.1" are all refused.
+
The code lives in morphir.ir.diagnostics as SPECS_BEFORE_3_1 = "specs_before_3_1", the
+upstream spelling.
+
+
V3-3. A Specs distribution's own modules are read strictly
+
The elm-faithful module-specification decoder ignores unknown members. Under it, a module definition
+({"access": ..., "value": ...}) would fail only on a missing types, and a module carrying extra
+members would read silently. For a Specs distribution's own modules, the decoder first refuses any member
+other than types, values and doc with unknown_member at that
+member. Dependencies keep the existing reading, as in Rust (SpecsModuleEntry versus
+ModuleSpecEntry).
+
Difference from Rust: Rust's strict reader defaults a missing types or values to empty.
+This package keeps them required (missing_member), as its 3.0.0 decoder does for every module
+specification. Every known writer emits both, so no real document is affected.
+
+
V3-4. Writers emit the lowest version that fits
+
encode_versioned_distribution picks the version from the content: V3 (written
+3) for a Library, V3_1 (written "3.1.0") for a Specs. The
+spelling comes from the existing write_format_version. A Library read at
+"3.1.0" is therefore written as 3. This is decision 0018's rule, and it keeps every
+Library readable by morphir-elm.
+
+
V3-5. finos/morphir is normative for 3.1.0 additions
+
morphir-elm stays normative for 3.0.0 content, but it has no Specs. For what 3.1.0 adds, the finos/morphir
+v3 spec and kit win, and morphir-rust cd9f7fb is the reference implementation. The unknown-tag
+diagnostic now names both kinds.
+
+
MIG-1. Typed overloads; specifications reuse the existing mappings
+
v3_to_v4 and v4_to_v3 keep their names and gain @overload signatures, so a
+caller that passes a Library still gets a Library type back:
A v3 Specs maps its package specification with _package_spec_up, the function that already
+maps dependencies. A v4 Specs maps with _Downgrade.package_spec, so it collects the same
+diagnostics (names, attributes, documentation) and warns package_version_dropped the same way. An
+Application is still refused with unsupported_v4_downgrade; decision 0018 says it has no v3
+form. The Library path is untouched.
+
Alternative: new functions specs_v3_to_v4 and specs_v4_to_v3. Rejected: the CLI would need
+its own dispatch on kind, and the existing Specs refusal in v4_to_v3 would remain a trap.
+
+
CLI-1. The CLI dispatches on the decoded kind
+
ir inspect reports kind: Specs for a v3 Specs and the version as read
+(format: 3.1.0). ir convert passes the decoded v3 distribution to v3_to_v4 or the
+decoded v4 distribution to v4_to_v3, and writes with the codec's writer, so the lowest-version rule
+applies to --to v3 too. The help text names 3.0.x and 3.1.x.
+
+
SCOPE-1. v3 document trees are out of scope
+
Decision 0018 also adds v3 JSON and YAML document trees, which reuse the v4 tree layout (logical paths, stem
+escaping, path budget, fileNames, manifest version rules). morphir-python has no document tree reader or
+writer for v4 either, so a v3 tree would mean building the whole layout first. That is a separate capability with
+its own kit cases (document-tree-0010 to 0017). This change leaves it out, and the follow-up bead
+morphir-ukvn.8.17 tracks it. Without trees, the single-file rules are complete: a Specs or a
+Library at any supported 3.x release reads, and every write is single-file.
+
+
TST-1. Fixtures from the kit and the reference tests
+
Two small fixtures go into tests/fixtures/ir/v3/, recorded in PROVENANCE.md:
+
+
mck-distributions-0011-specs.json: the canonical JSON of kit case distributions-0011 (finos/morphir
+efa36060, spec/ir/mck/distributions.feature).
+
rust-classic-specs-transport.json: the SPECS document of morphir-rust
+cd9f7fb, crates/morphir-common/tests/classic_specs_transport.rs, which adds a dependency.
+
+
Both are inline strings upstream, so they are copied verbatim as the file content rather than byte-for-byte from a
+file. Unit tests round-trip them, compare the output to the kit's canonical JSON, and run them through the
+migrations; BDD scenarios run them through the CLI.
+
+
Risks and trade-offs
+
+
Risk
Mitigation
+
+
Callers that typed decode_versioned_distribution as returning Library now get a union.
Breaking change named in the proposal and README. The migration overloads keep Library callers precise.
+
A Specs this package writes is refused by morphir-elm and other readers still on [3.0.0,3.1.0).
Intended by decision 0018: only new content carries the new version, and a Library still writes 3.
+
Strictness differs from Rust when a Specs module omits types or values (V3-3).
No known writer omits them; the difference is stated here and in the README.
+
The v4 model changes in parallel (morphir-ukvn.8.14) and could conflict with migrate.py.
Edits stay on the Specs branch of the two public functions; no v4 file is edited.
+
+
+
+
Migration plan
+
No data migration. Every 3.0.0 file reads and writes as before. Callers that matched on the v3 decoder
+result add a Specs case. Rollback is a revert of the change; no stored state depends on it.
+
+
Open questions
+
+
finos/morphir-rust#262 tracks whether a v3 tree reader should answer the minor diagnostic for a later 3.x
+manifest. It matters only when SCOPE-1's follow-up implements trees.
+
+
+
+
diff --git a/openspec/changes/add-ir-3-1-0/proposal.md b/openspec/changes/add-ir-3-1-0/proposal.md
new file mode 100644
index 00000000..36aa4204
--- /dev/null
+++ b/openspec/changes/add-ir-3-1-0/proposal.md
@@ -0,0 +1,41 @@
+# Proposal
+
+## Why
+
+IR format `3.1.0` is a minor revision of the classic IR (finos/morphir decision record 0018, issue finos/morphir#970). It adds a v3 `Specs` distribution, which publishes the public face of a package without its definitions. The reference support table is now `[3.0.0,3.2.0),[4.0.0,4.1.0)`, and morphir-rust reads and writes `3.1.0` (finos/morphir-rust `cd9f7fb`). morphir-python still declares `[3.0.0,3.1.0)` for v3, so it refuses every `3.1.0` file with `unsupported_format_version_minor`, and it cannot write a v4 `Specs` distribution as v3. Issue [finos/morphir-python#28](https://github.com/finos/morphir-python/issues/28) asks to close that gap.
+
+## What Changes
+
+- Raise the support table to `[3.0.0,3.2.0),[4.0.0,4.1.0)`. `"3.1.0"` (and every `3.1.x`) is supported; `"3.2.0"` still fails with `unsupported_format_version_minor`. **BREAKING** for a caller that relies on `3.1.0` being refused.
+- Add a v3 `Specs` distribution to `morphir.ir.v3`: `["Specs", package, dependencies, { modules }]`, where the fourth element is a package specification. A v3 distribution is now `Library | Specs`. **BREAKING** for a caller that types the v3 decoder result as `Library` only.
+- Refuse a `Specs` distribution that declares a release below `3.1.0` (`3`, `"3"`, `"3.0.x"`) with the new diagnostic `specs_before_3_1`. Refuse a module definition among a `Specs` distribution's own modules.
+- Writers emit the lowest version that fits: a v3 `Library` keeps `formatVersion: 3`, even when it was read as `"3.1.0"`; a v3 `Specs` writes `"3.1.0"`.
+- Migrations: a v3 `Specs` migrates to a v4 `Specs` (`formatVersion` 4), and a v4 `Specs` downgrades to a v3 `Specs` with the same rules and diagnostics as the dependency specifications of a `Library`. An `Application` still has no v3 form.
+- `morphir-tools ir inspect` reports a v3 `Specs`; `ir convert` converts `Specs` in both directions.
+- Document the change in `packages/morphir/README.md` ("Morphir IR versions") and the `morphir-tools` README.
+- v3 document trees, the other half of decision 0018, stay out of scope: morphir-python has no document tree for v4 either. Follow-up bead `morphir-ukvn.8.17` tracks them.
+
+## Capabilities
+
+### New Capabilities
+
+- (none)
+
+### Modified Capabilities
+
+- `ir/format-version`: the support table becomes `[3.0.0,3.2.0),[4.0.0,4.1.0)`.
+- `ir/v3`: the v3 model and codec gain the `Specs` distribution, the `specs_before_3_1` refusal, the strict reading of a `Specs` distribution's own modules, and the lowest-version writer rule; finos/morphir is normative for what `3.1.0` adds.
+- `ir/migrate`: `Specs` migrates in both directions; only `Application` is refused on downgrade.
+- `tools/ir-interchange`: inspect and convert handle a v3 `Specs` and `3.1.0` input; the scope limit moves from `3.1.x` to later 3.x minors.
+
+## Beads
+
+- **Tracked by**: `morphir-ukvn.8.13`
+- **Related**: `morphir-ukvn.8` (parent epic, first-class IR v3 alongside v4), `morphir-f22s` (closed finos/morphir work that specified v3 document trees, finos/morphir#970), `morphir-ukvn.8.14` (realigns the v4 model in parallel; this change does not touch the v4 model or codec), `morphir-ukvn.8.17` (follow-up: v3 and v4 document trees)
+
+## Impact
+
+- `packages/morphir`: `ir.format_version` (support table, messages), `ir.diagnostics` (`specs_before_3_1`), `ir.v3.distribution` and `ir.v3.codec` (`Specs`), `ir.migrate` (`Specs` path only). The v4 model and codec do not change. The core package stays dependency-free.
+- `packages/morphir-tools`: `ir inspect` and `ir convert`.
+- Tests and fixtures: new `3.1.0` fixtures in `tests/fixtures/ir/v3/` with provenance (MCK case distributions-0011 and the morphir-rust test documents); BDD scenarios in `features/ir_interchange.feature`.
+- Users: files with `formatVersion` `"3.1.0"` now load; a reader that still declares `[3.0.0,3.1.0)` (morphir-elm today) refuses the `Specs` files this package writes, as decision 0018 intends.
diff --git a/openspec/changes/add-ir-3-1-0/specs/ir/format-version/spec.md b/openspec/changes/add-ir-3-1-0/specs/ir/format-version/spec.md
new file mode 100644
index 00000000..1bf16cb2
--- /dev/null
+++ b/openspec/changes/add-ir-3-1-0/specs/ir/format-version/spec.md
@@ -0,0 +1,22 @@
+## REMOVED Requirements
+
+### Requirement: Support table is checked before decoding
+**Reason**: The table now includes IR 3.1, so the scenario that refused `"3.1.0"` no longer holds.
+**Migration**: See the requirement "Support table with IR 3.1 is checked before decoding".
+
+## ADDED Requirements
+
+### Requirement: Support table with IR 3.1 is checked before decoding
+The reader SHALL declare the support table `[3.0.0,3.2.0),[4.0.0,4.1.0)` and MUST finish recognition and the support check before invoking any distribution decoder or migration. A release outside the table SHALL fail with `unsupported_format_version_major` or `unsupported_format_version_minor`. (bead: morphir-ukvn.8.13)
+
+#### Scenario: IR 3.1.0 is supported
+- **WHEN** a document declares `formatVersion` `"3.1.0"` or `"3.1.4"`
+- **THEN** the support check passes and the distribution is decoded
+
+#### Scenario: A later 3.x minor is refused
+- **WHEN** a document declares `formatVersion` `"3.2.0"`
+- **THEN** reading fails with `unsupported_format_version_minor` before the distribution is decoded, and the message names the accepted table `[3.0.0,3.2.0),[4.0.0,4.1.0)`
+
+#### Scenario: Historical versions are refused
+- **WHEN** a document declares `formatVersion` `1` or `2`
+- **THEN** reading fails with `unsupported_format_version_major` and a message that formats 1 and 2 are follow-up work
diff --git a/openspec/changes/add-ir-3-1-0/specs/ir/migrate/spec.md b/openspec/changes/add-ir-3-1-0/specs/ir/migrate/spec.md
new file mode 100644
index 00000000..bf70a9e5
--- /dev/null
+++ b/openspec/changes/add-ir-3-1-0/specs/ir/migrate/spec.md
@@ -0,0 +1,53 @@
+## REMOVED Requirements
+
+### Requirement: Reverse migrate v4 to v3 with known failure paths
+**Reason**: A v4 Specs now downgrades to a v3 Specs, so the scenario that refused Specs no longer holds.
+**Migration**: See the requirement "Reverse migrate v4 Library and Specs to v3 with known failure paths".
+
+## ADDED Requirements
+
+### Requirement: Reverse migrate v4 Library and Specs to v3 with known failure paths
+The library SHALL migrate v4 to v3. When the input uses concepts v3 cannot represent, migration SHALL fail with structured errors and MUST NOT invent or drop IR content, except as allowed by the dropped-information requirement. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Application does not downgrade
+- **WHEN** a v4 Application distribution is migrated to v3
+- **THEN** migration fails with `unsupported_v4_downgrade` naming the distribution kind, because format 3 has no Application
+
+#### Scenario: V4-only nodes are reported
+- **WHEN** a v4 Library contains a `Hole`, `Native`, or `External` value, or a `DocumentLiteral`
+- **THEN** migration fails with `unsupported_v4_downgrade` naming the construct and its location
+
+#### Scenario: Missing types are reported
+- **WHEN** a v4 value, pattern, or value-definition input has no `inferred_type`
+- **THEN** migration fails with `missing_inferred_type` at that location
+
+#### Scenario: Semantic attributes are reported
+- **WHEN** v4 type attributes carry a source, constraints, or extensions, or value attributes carry extensions
+- **THEN** migration fails with `unsupported_attributes_downgrade`
+
+#### Scenario: Absent member documentation is reported
+- **WHEN** a v4 type or value member has no documentation
+- **THEN** migration fails with `unsupported_documentation_downgrade`, because v3 member documentation is a required string
+
+#### Scenario: Compatible Library downgrades
+- **WHEN** a v4 Library uses only concepts v3 can represent
+- **THEN** migration succeeds with a v3 Library
+
+### Requirement: Specs migrate between v3 and v4
+The library SHALL migrate a v3 `Specs` to a v4 `Specs` and a v4 `Specs` to a v3 `Specs`. Each specification SHALL map as the dependency specifications of a `Library` map, with the same diagnostics, warnings and caller-supplied package version. (bead: morphir-ukvn.8.13)
+
+#### Scenario: V3 Specs migrates forward
+- **WHEN** a v3 Specs is migrated to v4
+- **THEN** the result is a v4 Specs whose package name, dependencies and module specifications match the source
+
+#### Scenario: Compatible v4 Specs downgrades
+- **WHEN** a v4 Specs uses only concepts v3 can represent
+- **THEN** migration succeeds with a v3 Specs, and a package version is dropped with `package_version_dropped`
+
+#### Scenario: Incompatible v4 Specs is reported
+- **WHEN** a v4 Specs has a module member without documentation
+- **THEN** migration fails with `unsupported_documentation_downgrade` at that member
+
+#### Scenario: V3 Specs survives v3 to v4 to v3
+- **WHEN** a v3 Specs is migrated to v4 and back
+- **THEN** the result is semantically equal to the original
diff --git a/openspec/changes/add-ir-3-1-0/specs/ir/v3/spec.md b/openspec/changes/add-ir-3-1-0/specs/ir/v3/spec.md
new file mode 100644
index 00000000..77026acc
--- /dev/null
+++ b/openspec/changes/add-ir-3-1-0/specs/ir/v3/spec.md
@@ -0,0 +1,88 @@
+## MODIFIED Requirements
+
+### Requirement: morphir-elm is normative for v3 content
+The v3 model and JSON shapes SHALL follow morphir-elm `Morphir.IR.*` types and `*.Codec` modules for format 3.0.0. Where finos/morphir v3 schemas or docs disagree with morphir-elm on 3.0.0 content, morphir-elm MUST win. What format 3.1.0 adds, which morphir-elm does not implement, SHALL follow the finos/morphir v3 specification and decision record 0018. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Elm output decodes
+- **WHEN** a `morphir-ir.json` written by morphir-elm `morphir make` is decoded
+- **THEN** decoding succeeds without a Python-specific JSON variant
+
+#### Scenario: The finos 3.1.0 example decodes
+- **WHEN** the v3 `Specs` document of the finos/morphir kit case distributions-0011 is decoded and encoded again
+- **THEN** decoding succeeds and the output is equal to the kit's canonical JSON
+
+### Requirement: V3 model mirrors morphir-elm concepts
+The library SHALL expose a v3 model whose concepts match morphir-elm: names as word lists, paths, qualified and fully qualified names, access control, documented values, literals, types, values, patterns, modules, packages, and the `Library` distribution, plus the `Specs` distribution of format 3.1.0. A v3 `Library` SHALL carry type attributes of unit and value attributes of a v3 type, as in elm. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Library distribution is representable
+- **WHEN** a caller constructs a v3 Library with a package name, dependency specifications, and a package definition whose values carry types
+- **THEN** the model keeps those parts without any v4-only construct
+
+#### Scenario: Library has no package version
+- **WHEN** a caller constructs a v3 Library
+- **THEN** the model has no package release version, because format 3.0.0 has none
+
+#### Scenario: Specs distribution is representable
+- **WHEN** a caller constructs a v3 Specs with a package name, dependency specifications, and a package specification
+- **THEN** the model keeps those parts, holds no definitions, and has no package release version
+
+### Requirement: V3 JSON matches morphir-elm format 3.0.0
+The library SHALL encode and decode versioned distribution JSON with the shapes of morphir-elm `Morphir.IR.FormatVersion.Codec` and the codecs it uses. Node codecs SHALL take attribute codecs as parameters, as elm does. Version recognition SHALL use the shared format-version reader. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Round trip versioned morphir-ir.json
+- **WHEN** a valid morphir-elm `morphir-ir.json` with `formatVersion` 3 is decoded and encoded again
+- **THEN** the output declares `formatVersion` 3 and its distribution is semantically equal to the input
+
+#### Scenario: String version is accepted on read
+- **WHEN** an otherwise valid format-3 document declares `formatVersion` `"3.0.0"`
+- **THEN** it decodes, and encoding writes the integer `3`
+
+#### Scenario: Wrong family at the v3 entry point
+- **WHEN** a document with `formatVersion` 4 is passed to the v3 decoder
+- **THEN** decoding fails with `unsupported_format_version_major` naming the found version and that only 3.0.x and 3.1.x are accepted
+
+#### Scenario: Unknown distribution kind
+- **WHEN** a format-3 document has a distribution tag other than `Library` or `Specs`
+- **THEN** decoding fails with `unknown_node` naming the tag and both known kinds
+
+## ADDED Requirements
+
+### Requirement: V3 Specs distribution JSON
+The library SHALL encode and decode a v3 `Specs` distribution as `["Specs", package, dependencies, specification]`, where `specification` is a package specification whose modules are spelled as a dependency's modules are. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Specs round trips
+- **WHEN** a document with `formatVersion` `"3.1.0"` and a `Specs` distribution is decoded and encoded again
+- **THEN** the output declares `formatVersion` `"3.1.0"` and its distribution is semantically equal to the input
+
+#### Scenario: A Specs distribution with dependencies
+- **WHEN** a `Specs` distribution lists dependency package specifications
+- **THEN** they decode as the dependencies of a `Library` do
+
+### Requirement: A Specs distribution needs 3.1.0 or later
+The v3 reader SHALL refuse a `Specs` distribution whose document declares a release below `3.1.0`, such as `3`, `"3"` or `"3.0.1"`, with `specs_before_3_1` at the `formatVersion` member. A `Library` SHALL stay readable at every supported 3.x release. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Specs declared as 3 is refused
+- **WHEN** a document declares `formatVersion` `3`, `"3"`, `"3.0.0"` or `"3.0.1"` and holds a `Specs` distribution
+- **THEN** decoding fails with `specs_before_3_1`
+
+#### Scenario: Library declared as 3.1.0 is read
+- **WHEN** a document declares `formatVersion` `"3.1.0"` and holds a `Library` distribution
+- **THEN** it decodes as a v3 Library
+
+### Requirement: A Specs distribution holds specifications only
+The v3 reader SHALL refuse a member other than `types`, `values` and `doc` in a module of a `Specs` distribution's own package specification with `unknown_member`, so a module definition is not read as a specification. Dependency modules keep the morphir-elm reading. (bead: morphir-ukvn.8.13)
+
+#### Scenario: A module definition in a Specs is refused
+- **WHEN** a `Specs` distribution's own module is spelled `{"access": "Public", "value": {...}}`
+- **THEN** decoding fails with `unknown_member` at that module
+
+### Requirement: Writers emit the lowest version that fits
+The v3 writer SHALL emit `formatVersion` `3` for a `Library` and `"3.1.0"` for a `Specs`, whatever version the input declared, so every reader of 3.0.0 still reads every `Library` this library writes. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Library read as 3.1.0 is written as 3
+- **WHEN** a `Library` read from a document that declares `"3.1.0"` is encoded
+- **THEN** the output declares the integer `formatVersion` `3`
+
+#### Scenario: Specs is written as 3.1.0
+- **WHEN** a v3 `Specs` is encoded
+- **THEN** the output declares `formatVersion` `"3.1.0"`
diff --git a/openspec/changes/add-ir-3-1-0/specs/tools/ir-interchange/spec.md b/openspec/changes/add-ir-3-1-0/specs/tools/ir-interchange/spec.md
new file mode 100644
index 00000000..9771e5f5
--- /dev/null
+++ b/openspec/changes/add-ir-3-1-0/specs/tools/ir-interchange/spec.md
@@ -0,0 +1,54 @@
+## MODIFIED Requirements
+
+### Requirement: Inspect versioned morphir-ir.json
+The `ir inspect` command SHALL read a file with the shared format-version reader, decode it with the matching v3 or v4 codec, and report the normalized format version, the distribution kind, and the package name. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Report format version 3
+- **WHEN** the user inspects a morphir-elm `morphir-ir.json` with `formatVersion` 3
+- **THEN** the tool reports format 3.0.0, kind Library, and the package name, and exits 0
+
+#### Scenario: Report a v3 Specs
+- **WHEN** the user inspects a file with `formatVersion` `"3.1.0"` and a `Specs` distribution
+- **THEN** the tool reports format 3.1.0, kind Specs, and the package name, and exits 0
+
+#### Scenario: Unversioned IR is rejected clearly
+- **WHEN** the input JSON has no `formatVersion` member
+- **THEN** the tool exits non-zero, reports `missing_format_version`, and says the IR should be regenerated
+
+#### Scenario: Draft v4 is labelled
+- **WHEN** the user inspects a v4 file
+- **THEN** the report states that v4 support is draft
+
+### Requirement: Convert between v3 and v4
+The `ir convert` command SHALL convert format 3 (3.0.x and 3.1.x) to v4 and v4 to format 3 through the library migrations, for `Library` and `Specs` distributions. It SHALL accept an optional package version for upgrades and an opt-in flag to drop source locations on downgrade. Success writes the target file; failure exits non-zero, prints each diagnostic, and leaves no output file. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Upgrade v3 file to v4
+- **WHEN** the user converts a valid format-3 Library file to v4
+- **THEN** the tool writes a `formatVersion` 4 file and exits 0
+
+#### Scenario: Specs converts both ways
+- **WHEN** the user converts a v3 Specs file to v4, or a compatible v4 Specs file to v3
+- **THEN** the tool writes a v4 Specs with `formatVersion` 4, or a v3 Specs with `formatVersion` `"3.1.0"`, and exits 0
+
+#### Scenario: Downgrade failure is visible
+- **WHEN** the user converts a v4 file that cannot be represented in format 3
+- **THEN** the tool exits non-zero, prints each diagnostic code and location, and does not create the output file
+
+#### Scenario: Downgrade warnings are printed
+- **WHEN** a downgrade succeeds with dropped information
+- **THEN** the tool prints each warning code and still exits 0
+
+## REMOVED Requirements
+
+### Requirement: Scope excludes formats 1, 2, and 3.1
+**Reason**: IR 3.1.0 is now supported; only formats 1 and 2 and later 3.x minors stay out of scope.
+**Migration**: See the requirement "Scope excludes formats 1 and 2 and later 3.x minors".
+
+## ADDED Requirements
+
+### Requirement: Scope excludes formats 1 and 2 and later 3.x minors
+The tools MUST reject formats 1 and 2 and any 3.x release from 3.2.0 on, rather than load or convert them. This differs from morphir-elm, which still reads format 1. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Unsupported format version is named
+- **WHEN** the user supplies a file whose `formatVersion` is 1, 2, or `"3.2.0"`
+- **THEN** the tool exits non-zero with the format-version diagnostic code and says only 3.0.x, 3.1.x and draft 4.0.x are supported
diff --git a/openspec/changes/add-ir-3-1-0/tasks.md b/openspec/changes/add-ir-3-1-0/tasks.md
new file mode 100644
index 00000000..de7b6d60
--- /dev/null
+++ b/openspec/changes/add-ir-3-1-0/tasks.md
@@ -0,0 +1,35 @@
+# Tasks
+
+## 1. Format-version support table for IR 3.1 (morphir-ukvn.8.13.1)
+
+- [ ] 1.1 Write failing tests in `tests/unit/ir/test_format_version.py`: `"3.1.0"` and `"3.1.4"` are supported, `"3.2.0"` fails with `unsupported_format_version_minor` and names `[3.0.0,3.2.0),[4.0.0,4.1.0)`, and the description lists `3.0.x, 3.1.x and draft 4.0.x`; verify they fail
+- [ ] 1.2 Raise `V3_INTERVAL` to `[3.0.0,3.2.0)`, add `V3_1`, describe each minor family, and drop the issue #28 pointer (design FV-1); verify the format-version tests pass
+- [ ] 1.3 Update existing tests and docstrings that assumed `[3.0.0,3.1.0)` (`test_format_version.py`, `test_v3_codec.py`, `tests/tools/test_cli.py`); verify `uv run pytest tests/` passes
+
+## 2. V3 Specs distribution model and codec (morphir-ukvn.8.13.2)
+
+- [ ] 2.1 Copy the kit case distributions-0011 and the morphir-rust `SPECS` document into `tests/fixtures/ir/v3/` and record both in `PROVENANCE.md` (design TST-1); verify the files parse as JSON
+- [ ] 2.2 Write failing codec tests: the fixtures round trip and the kit fixture re-encodes to its canonical JSON; `Specs` at `3`, `"3"`, `"3.0.0"`, `"3.0.1"` fails with `specs_before_3_1`; a `Library` at `"3.1.0"` reads and writes `3`; a module definition in a `Specs` fails with `unknown_member`; an unknown tag names both kinds; verify they fail
+- [ ] 2.3 Add `SPECS_BEFORE_3_1` to `morphir.ir.diagnostics`, the `Specs` dataclass and `Distribution = Library | Specs` to `morphir.ir.v3.distribution`, and export `Specs` from `morphir.ir.v3` (design V3-1)
+- [ ] 2.4 Decode and encode `Specs` in `morphir.ir.v3.codec`: strict own modules, the version check before the body, and the lowest-version writer (design V3-2 to V3-5); verify the codec tests pass with mypy and pyright strict
+
+## 3. V3 and v4 Specs migrations (morphir-ukvn.8.13.3)
+
+- [ ] 3.1 Write failing migration tests: a v3 `Specs` migrates to a v4 `Specs`; a compatible v4 `Specs` downgrades with `package_version_dropped`; a v4 `Specs` without member documentation fails with `unsupported_documentation_downgrade`; an `Application` still fails with `unsupported_v4_downgrade`; the fixtures survive v3 to v4 to v3; verify they fail
+- [ ] 3.2 Add `Specs` branches and typed overloads to `v3_to_v4` and `v4_to_v3` without touching the `Library` path or any v4 file (design MIG-1); verify the migration tests and doctests pass
+
+## 4. IR interchange CLI and docs for IR 3.1 (morphir-ukvn.8.13.4)
+
+- [ ] 4.1 Write failing CLI tests in `tests/tools/test_cli.py` and BDD scenarios in `features/ir_interchange.feature`: inspect a v3 `Specs` reports `format: 3.1.0` and `kind: Specs`; convert a v3 `Specs` to v4 and back; `"3.2.0"` is refused naming `3.0.x, 3.1.x and draft 4.0.x`; verify they fail
+- [ ] 4.2 Dispatch on the decoded v3 kind in `ir inspect` and `ir convert` and update the help text (design CLI-1); verify the CLI tests and `mise run test-bdd` pass
+- [ ] 4.3 Update `packages/morphir/README.md` ("Morphir IR versions") and `packages/morphir-tools/README.md` for 3.1.0, `Specs`, `specs_before_3_1`, the lowest-version writer and the document-tree scope; verify `tests/unit/ir/test_ir_docs.py` passes
+
+## 5. Integration checks for IR 3.1 (morphir-ukvn.8.13.5)
+
+- [ ] 5.1 Run `mise run check` (lint, format check, mypy and pyright strict, tests, coverage at least 80%) and `mise run test-bdd`; verify both pass
+- [ ] 5.2 Run `openspec validate add-ir-3-1-0 --strict`; verify it reports the change as valid
+
+## Workflow follow-up
+
+- Archive the change in the same branch with the `openspec-archive-change` skill, fix the design asset prefix, and set the epic spec id to the archived path.
+- Open the pull request; the coordinator merges it and closes `morphir-ukvn.8.13`.
From ba12803ab18072436346958a78c3d0e0573fc4af Mon Sep 17 00:00:00 2001
From: Damian Reeves <957246+DamianReeves@users.noreply.github.com>
Date: Tue, 27 Oct 2026 02:00:53 -0500
Subject: [PATCH 2/7] feat(ir): raise the support table to
[3.0.0,3.2.0),[4.0.0,4.1.0)
IR 3.1.0 is now a supported release. A later 3.x minor such as 3.2.0 is
still refused with unsupported_format_version_minor, and the message lists
each minor family the table covers.
---
openspec/changes/add-ir-3-1-0/tasks.md | 6 +--
.../morphir/src/morphir/ir/format_version.py | 37 +++++++++++--------
packages/morphir/src/morphir/ir/v3/codec.py | 2 +-
tests/tools/test_cli.py | 4 +-
tests/unit/ir/test_format_version.py | 23 +++++++++---
tests/unit/ir/test_v3_codec.py | 2 +-
6 files changed, 47 insertions(+), 27 deletions(-)
diff --git a/openspec/changes/add-ir-3-1-0/tasks.md b/openspec/changes/add-ir-3-1-0/tasks.md
index de7b6d60..4293f034 100644
--- a/openspec/changes/add-ir-3-1-0/tasks.md
+++ b/openspec/changes/add-ir-3-1-0/tasks.md
@@ -2,9 +2,9 @@
## 1. Format-version support table for IR 3.1 (morphir-ukvn.8.13.1)
-- [ ] 1.1 Write failing tests in `tests/unit/ir/test_format_version.py`: `"3.1.0"` and `"3.1.4"` are supported, `"3.2.0"` fails with `unsupported_format_version_minor` and names `[3.0.0,3.2.0),[4.0.0,4.1.0)`, and the description lists `3.0.x, 3.1.x and draft 4.0.x`; verify they fail
-- [ ] 1.2 Raise `V3_INTERVAL` to `[3.0.0,3.2.0)`, add `V3_1`, describe each minor family, and drop the issue #28 pointer (design FV-1); verify the format-version tests pass
-- [ ] 1.3 Update existing tests and docstrings that assumed `[3.0.0,3.1.0)` (`test_format_version.py`, `test_v3_codec.py`, `tests/tools/test_cli.py`); verify `uv run pytest tests/` passes
+- [x] 1.1 Write failing tests in `tests/unit/ir/test_format_version.py`: `"3.1.0"` and `"3.1.4"` are supported, `"3.2.0"` fails with `unsupported_format_version_minor` and names `[3.0.0,3.2.0),[4.0.0,4.1.0)`, and the description lists `3.0.x, 3.1.x and draft 4.0.x`; verify they fail
+- [x] 1.2 Raise `V3_INTERVAL` to `[3.0.0,3.2.0)`, add `V3_1`, describe each minor family, and drop the issue #28 pointer (design FV-1); verify the format-version tests pass
+- [x] 1.3 Update existing tests and docstrings that assumed `[3.0.0,3.1.0)` (`test_format_version.py`, `test_v3_codec.py`, `tests/tools/test_cli.py`); verify `uv run pytest tests/` passes
## 2. V3 Specs distribution model and codec (morphir-ukvn.8.13.2)
diff --git a/packages/morphir/src/morphir/ir/format_version.py b/packages/morphir/src/morphir/ir/format_version.py
index bd4a238e..4a0d4034 100644
--- a/packages/morphir/src/morphir/ir/format_version.py
+++ b/packages/morphir/src/morphir/ir/format_version.py
@@ -12,9 +12,9 @@
integer ``N`` for ``N.0.0`` and the exact release string otherwise, so this package
never produces a file that either source would reject.
-The support table is ``[3.0.0,3.1.0),[4.0.0,4.1.0)``. It is narrower than the finos
-reference table because IR 3.1.0 is not implemented yet
-(https://github.com/finos/morphir-python/issues/28), and v4 support is draft.
+The support table is ``[3.0.0,3.2.0),[4.0.0,4.1.0)``, the finos reference table
+(finos/morphir decision record 0018). IR 3.1.0 added the v3 ``Specs``
+distribution; `V3_1` names that release. v4 support is draft.
Example:
>>> read_format_version(3)
@@ -71,13 +71,15 @@ def __str__(self) -> str:
SupportTable = tuple[Interval, ...]
-V3_INTERVAL = Interval(FormatVersion(3, 0, 0), FormatVersion(3, 1, 0))
+V3_INTERVAL = Interval(FormatVersion(3, 0, 0), FormatVersion(3, 2, 0))
V4_INTERVAL = Interval(FormatVersion(4, 0, 0), FormatVersion(4, 1, 0))
SUPPORT_TABLE: SupportTable = (V3_INTERVAL, V4_INTERVAL)
V3_TABLE: SupportTable = (V3_INTERVAL,)
V4_TABLE: SupportTable = (V4_INTERVAL,)
V3 = FormatVersion(3, 0, 0)
+V3_1 = FormatVersion(3, 1, 0)
+"""The release that introduced the v3 ``Specs`` distribution."""
V4 = FormatVersion(4, 0, 0)
@@ -203,26 +205,30 @@ def check_supported(
f"format version {version} is not supported ({detail}); "
f"this reader accepts only {_describe(table)} ({accepted})",
)
- detail = ""
- if version.major == 3:
- detail = (
- "; IR 3.1 is tracked by https://github.com/finos/morphir-python/issues/28"
- )
raise fail(
d.UNSUPPORTED_FORMAT_VERSION_MINOR,
_CONSTRUCT,
loc,
- f"format version {version} is not supported{detail}; "
+ f"format version {version} is not supported; "
f"this reader accepts only {_describe(table)} ({accepted})",
)
+def _families(interval: Interval) -> list[str]:
+ lower, upper = interval.lower, interval.upper
+ if upper.major == lower.major and upper.patch == 0:
+ minors = range(lower.minor, max(upper.minor, lower.minor + 1))
+ else:
+ minors = range(lower.minor, lower.minor + 1)
+ draft = "draft " if lower.major == 4 else ""
+ return [f"{draft}{lower.major}.{minor}.x" for minor in minors]
+
+
def _describe(table: SupportTable) -> str:
- names = []
- for interval in table:
- family = f"{interval.lower.major}.{interval.lower.minor}.x"
- names.append(f"draft {family}" if interval.lower.major == 4 else family)
- return " and ".join(names)
+ names = [name for interval in table for name in _families(interval)]
+ if len(names) == 1:
+ return names[0]
+ return f"{', '.join(names[:-1])} and {names[-1]}"
def read_format_version(
@@ -349,6 +355,7 @@ def dump_json(value: object) -> str:
__all__ = [
"SUPPORT_TABLE",
"V3",
+ "V3_1",
"V3_TABLE",
"V4",
"V4_TABLE",
diff --git a/packages/morphir/src/morphir/ir/v3/codec.py b/packages/morphir/src/morphir/ir/v3/codec.py
index 07efa394..ae637399 100644
--- a/packages/morphir/src/morphir/ir/v3/codec.py
+++ b/packages/morphir/src/morphir/ir/v3/codec.py
@@ -1111,7 +1111,7 @@ def decode_versioned_distribution(document: str | object) -> Library:
"""Decode a format-3 ``morphir-ir.json`` document.
The format version is read with the shared permissive reader and must be in
- ``[3.0.0,3.1.0)``; ``4`` fails with ``unsupported_format_version_major``.
+ ``[3.0.0,3.2.0)``; ``4`` fails with ``unsupported_format_version_major``.
Args:
document: JSON text or an already-decoded JSON value.
diff --git a/tests/tools/test_cli.py b/tests/tools/test_cli.py
index 8e6573a3..fd49289b 100644
--- a/tests/tools/test_cli.py
+++ b/tests/tools/test_cli.py
@@ -77,7 +77,7 @@ def test_missing_version(self, tmp_path: Path) -> None:
[
(1, d.UNSUPPORTED_FORMAT_VERSION_MAJOR),
(2, d.UNSUPPORTED_FORMAT_VERSION_MAJOR),
- ("3.1.0", d.UNSUPPORTED_FORMAT_VERSION_MINOR),
+ ("3.2.0", d.UNSUPPORTED_FORMAT_VERSION_MINOR),
],
)
def test_unsupported_versions(
@@ -87,7 +87,7 @@ def test_unsupported_versions(
result = run("ir", "inspect", write(tmp_path, "x.json", doc))
assert result.exit_code == 1
assert code in result.stderr
- assert "only 3.0.x and draft 4.0.x" in result.stderr
+ assert "only 3.0.x, 3.1.x and draft 4.0.x" in result.stderr
def test_unreadable_file(self, tmp_path: Path) -> None:
result = run("ir", "inspect", tmp_path / "missing.json")
diff --git a/tests/unit/ir/test_format_version.py b/tests/unit/ir/test_format_version.py
index 8925867c..fc215d0c 100644
--- a/tests/unit/ir/test_format_version.py
+++ b/tests/unit/ir/test_format_version.py
@@ -6,6 +6,7 @@
from morphir.ir.diagnostics import DiagnosticError
from morphir.ir.format_version import (
SUPPORT_TABLE,
+ V3_1,
V3_TABLE,
FormatVersion,
dump_json,
@@ -76,11 +77,23 @@ def test_historical_versions_are_follow_up_work(self, value: int) -> None:
assert info.value.codes == (d.UNSUPPORTED_FORMAT_VERSION_MAJOR,)
assert "formats 1 and 2 are follow-up work" in str(info.value)
- def test_ir_3_1_is_refused(self) -> None:
+ @pytest.mark.parametrize("value", ["3.1.0", "3.1.4"])
+ def test_ir_3_1_is_supported(self, value: str) -> None:
+ version = read_format_version(value)
+ assert (version.major, version.minor) == (3, 1)
+
+ def test_v3_1_names_the_release_that_added_specs(self) -> None:
+ assert FormatVersion(3, 1, 0) == V3_1
+ assert write_format_version(V3_1) == "3.1.0"
+
+ def test_later_v3_minor_is_refused(self) -> None:
with pytest.raises(DiagnosticError) as info:
- read_format_version("3.1.0")
+ read_format_version("3.2.0")
assert info.value.codes == (d.UNSUPPORTED_FORMAT_VERSION_MINOR,)
- assert "issues/28" in str(info.value)
+ message = str(info.value)
+ assert "3.0.x, 3.1.x and draft 4.0.x" in message
+ assert "[3.0.0,3.2.0),[4.0.0,4.1.0)" in message
+ assert "issues/28" not in message
def test_v4_minor_is_refused(self) -> None:
assert codes("4.1.0") == (d.UNSUPPORTED_FORMAT_VERSION_MINOR,)
@@ -92,10 +105,10 @@ def test_family_table_refuses_other_family(self) -> None:
with pytest.raises(DiagnosticError) as info:
read_format_version(4, V3_TABLE)
assert info.value.codes == (d.UNSUPPORTED_FORMAT_VERSION_MAJOR,)
- assert "only 3.0.x" in str(info.value)
+ assert "only 3.0.x and 3.1.x ([3.0.0,3.2.0))" in str(info.value)
def test_table_spelling(self) -> None:
- assert table_to_string(SUPPORT_TABLE) == "[3.0.0,3.1.0),[4.0.0,4.1.0)"
+ assert table_to_string(SUPPORT_TABLE) == "[3.0.0,3.2.0),[4.0.0,4.1.0)"
class TestWriting:
diff --git a/tests/unit/ir/test_v3_codec.py b/tests/unit/ir/test_v3_codec.py
index abaa5ed3..d916696b 100644
--- a/tests/unit/ir/test_v3_codec.py
+++ b/tests/unit/ir/test_v3_codec.py
@@ -328,7 +328,7 @@ def test_v4_is_refused(self) -> None:
)
assert err.codes == (d.UNSUPPORTED_FORMAT_VERSION_MAJOR,)
assert "4.0.0" in str(err)
- assert "only 3.0.x" in str(err)
+ assert "only 3.0.x and 3.1.x" in str(err)
def test_unknown_distribution_tag(self) -> None:
doc = {"formatVersion": 3, "distribution": ["Specs", [], [], {}]}
From c37a11f4d196c2358f5e3831068c115f6f7838c8 Mon Sep 17 00:00:00 2001
From: Damian Reeves <957246+DamianReeves@users.noreply.github.com>
Date: Tue, 27 Oct 2026 02:04:33 -0500
Subject: [PATCH 3/7] feat(ir): read, write and migrate the v3 Specs
distribution of IR 3.1.0
A v3 distribution is now Library or Specs. The codec reads a Specs from
formatVersion 3.1.0 on and refuses one declared below it with
specs_before_3_1, before the body is decoded. A Specs distribution's own
modules accept only types, values and doc, so a module definition is refused
with unknown_member. Writers emit the lowest version that fits: 3 for a
Library, "3.1.0" for a Specs.
v3_to_v4 and v4_to_v3 migrate Specs both ways with the dependency mappings
and diagnostics a Library uses, and keep one typed overload per kind. An
Application still has no format-3 form.
Fixtures come from finos/morphir efa36060 (kit case distributions-0011) and
finos/morphir-rust cd9f7fb.
---
openspec/changes/add-ir-3-1-0/design.html | 15 +-
openspec/changes/add-ir-3-1-0/tasks.md | 12 +-
.../morphir/src/morphir/ir/diagnostics.py | 5 +
packages/morphir/src/morphir/ir/migrate.py | 124 ++++++++++----
.../morphir/src/morphir/ir/v3/__init__.py | 12 +-
packages/morphir/src/morphir/ir/v3/codec.py | 162 ++++++++++++++----
.../morphir/src/morphir/ir/v3/distribution.py | 22 ++-
tests/fixtures/ir/v3/PROVENANCE.md | 16 +-
.../ir/v3/mck-distributions-0011-specs.json | 1 +
.../ir/v3/rust-classic-specs-transport.json | 1 +
tests/tools/test_cli.py | 7 +-
tests/unit/ir/test_migrate.py | 113 ++++++++++--
tests/unit/ir/test_v3_codec.py | 132 +++++++++++++-
13 files changed, 511 insertions(+), 111 deletions(-)
create mode 100644 tests/fixtures/ir/v3/mck-distributions-0011-specs.json
create mode 100644 tests/fixtures/ir/v3/rust-classic-specs-transport.json
diff --git a/openspec/changes/add-ir-3-1-0/design.html b/openspec/changes/add-ir-3-1-0/design.html
index ecc5da7f..c863e088 100644
--- a/openspec/changes/add-ir-3-1-0/design.html
+++ b/openspec/changes/add-ir-3-1-0/design.html
@@ -133,14 +133,23 @@
V3-5. finos/morphir is normative for 3.1.0 additions
diagnostic now names both kinds.
MIG-1. Typed overloads; specifications reuse the existing mappings
-
v3_to_v4 and v4_to_v3 keep their names and gain @overload signatures, so a
-caller that passes a Library still gets a Library type back:
+
v3_to_v4 and v4_to_v3 keep their names and gain one @overload per
+distribution kind, so a caller that passes a Library still gets a Library type back:
There is no catch-all overload for a union argument. Ok is invariant (a frozen dataclass has
+__replace__), so a union overload would overlap the others with an incompatible return type, which
+pyright refuses. mypy and pyright both expand a union argument across the overloads instead, so the CLI, which holds
+Library | Specs, gets Ok[...Library] | Ok[...Specs].
A v3 Specs maps its package specification with _package_spec_up, the function that already
maps dependencies. A v4 Specs maps with _Downgrade.package_spec, so it collects the same
diagnostics (names, attributes, documentation) and warns package_version_dropped the same way. An
diff --git a/openspec/changes/add-ir-3-1-0/tasks.md b/openspec/changes/add-ir-3-1-0/tasks.md
index 4293f034..5d30c48b 100644
--- a/openspec/changes/add-ir-3-1-0/tasks.md
+++ b/openspec/changes/add-ir-3-1-0/tasks.md
@@ -8,15 +8,15 @@
## 2. V3 Specs distribution model and codec (morphir-ukvn.8.13.2)
-- [ ] 2.1 Copy the kit case distributions-0011 and the morphir-rust `SPECS` document into `tests/fixtures/ir/v3/` and record both in `PROVENANCE.md` (design TST-1); verify the files parse as JSON
-- [ ] 2.2 Write failing codec tests: the fixtures round trip and the kit fixture re-encodes to its canonical JSON; `Specs` at `3`, `"3"`, `"3.0.0"`, `"3.0.1"` fails with `specs_before_3_1`; a `Library` at `"3.1.0"` reads and writes `3`; a module definition in a `Specs` fails with `unknown_member`; an unknown tag names both kinds; verify they fail
-- [ ] 2.3 Add `SPECS_BEFORE_3_1` to `morphir.ir.diagnostics`, the `Specs` dataclass and `Distribution = Library | Specs` to `morphir.ir.v3.distribution`, and export `Specs` from `morphir.ir.v3` (design V3-1)
-- [ ] 2.4 Decode and encode `Specs` in `morphir.ir.v3.codec`: strict own modules, the version check before the body, and the lowest-version writer (design V3-2 to V3-5); verify the codec tests pass with mypy and pyright strict
+- [x] 2.1 Copy the kit case distributions-0011 and the morphir-rust `SPECS` document into `tests/fixtures/ir/v3/` and record both in `PROVENANCE.md` (design TST-1); verify the files parse as JSON
+- [x] 2.2 Write failing codec tests: the fixtures round trip and the kit fixture re-encodes to its canonical JSON; `Specs` at `3`, `"3"`, `"3.0.0"`, `"3.0.1"` fails with `specs_before_3_1`; a `Library` at `"3.1.0"` reads and writes `3`; a module definition in a `Specs` fails with `unknown_member`; an unknown tag names both kinds; verify they fail
+- [x] 2.3 Add `SPECS_BEFORE_3_1` to `morphir.ir.diagnostics`, the `Specs` dataclass and `Distribution = Library | Specs` to `morphir.ir.v3.distribution`, and export `Specs` from `morphir.ir.v3` (design V3-1)
+- [x] 2.4 Decode and encode `Specs` in `morphir.ir.v3.codec`: strict own modules, the version check before the body, and the lowest-version writer (design V3-2 to V3-5); verify the codec tests pass with mypy and pyright strict
## 3. V3 and v4 Specs migrations (morphir-ukvn.8.13.3)
-- [ ] 3.1 Write failing migration tests: a v3 `Specs` migrates to a v4 `Specs`; a compatible v4 `Specs` downgrades with `package_version_dropped`; a v4 `Specs` without member documentation fails with `unsupported_documentation_downgrade`; an `Application` still fails with `unsupported_v4_downgrade`; the fixtures survive v3 to v4 to v3; verify they fail
-- [ ] 3.2 Add `Specs` branches and typed overloads to `v3_to_v4` and `v4_to_v3` without touching the `Library` path or any v4 file (design MIG-1); verify the migration tests and doctests pass
+- [x] 3.1 Write failing migration tests: a v3 `Specs` migrates to a v4 `Specs`; a compatible v4 `Specs` downgrades with `package_version_dropped`; a v4 `Specs` without member documentation fails with `unsupported_documentation_downgrade`; an `Application` still fails with `unsupported_v4_downgrade`; the fixtures survive v3 to v4 to v3; verify they fail
+- [x] 3.2 Add `Specs` branches and typed overloads to `v3_to_v4` and `v4_to_v3` without touching the `Library` path or any v4 file (design MIG-1); verify the migration tests and doctests pass
## 4. IR interchange CLI and docs for IR 3.1 (morphir-ukvn.8.13.4)
diff --git a/packages/morphir/src/morphir/ir/diagnostics.py b/packages/morphir/src/morphir/ir/diagnostics.py
index 0180111f..83214f24 100644
--- a/packages/morphir/src/morphir/ir/diagnostics.py
+++ b/packages/morphir/src/morphir/ir/diagnostics.py
@@ -5,6 +5,8 @@
- format-version codes come from the finos/morphir format-version contract
(``docs/spec/ir/format-version.md``);
+- ``specs_before_3_1`` comes from the finos/morphir v3 specification
+ (``docs/spec/ir/schemas/v3/whats-new.md``, decision record 0018);
- decoding codes come from the Morphir Compatibility Kit (morphir-rust
``ir/diagnostic.rs``);
- downgrade codes come from the finos CLI and morphir-moonbit
@@ -28,6 +30,9 @@
UNSUPPORTED_FORMAT_VERSION_MAJOR = "unsupported_format_version_major"
UNSUPPORTED_FORMAT_VERSION_MINOR = "unsupported_format_version_minor"
+# A v3 Specs distribution declared below 3.1.0 (finos/morphir decision record 0018).
+SPECS_BEFORE_3_1 = "specs_before_3_1"
+
# Decoding (Morphir Compatibility Kit).
UNKNOWN_NODE = "unknown_node"
UNKNOWN_MEMBER = "unknown_member"
diff --git a/packages/morphir/src/morphir/ir/migrate.py b/packages/morphir/src/morphir/ir/migrate.py
index 2097cc63..4ca3ce35 100644
--- a/packages/morphir/src/morphir/ir/migrate.py
+++ b/packages/morphir/src/morphir/ir/migrate.py
@@ -1,8 +1,9 @@
-"""Migrations between Morphir IR format 3.0.0 and IR v4.
+"""Migrations between Morphir IR format 3 and IR v4.
-`v3_to_v4` always succeeds for a valid v3 Library. `v4_to_v3` collects every
-construct format 3.0.0 cannot represent in one pass and fails with all of them, so
-users fix every problem at once. Nothing is invented or dropped, except:
+`v3_to_v4` always succeeds for a valid v3 Library or Specs. `v4_to_v3` collects
+every construct format 3 cannot represent in one pass and fails with all of them,
+so users fix every problem at once. A v4 Specs becomes a v3 Specs (format 3.1.0);
+a v4 Application has no v3 form. Nothing is invented or dropped, except:
- the package release version, which v3 cannot hold (``package_version_dropped``);
- value source locations, only when ``drop_source_locations=True``
@@ -29,7 +30,7 @@
import dataclasses
import re
from dataclasses import dataclass, field
-from typing import cast
+from typing import cast, overload
from . import diagnostics as d
from .diagnostics import Diagnostic, Location
@@ -340,19 +341,34 @@ def _package_def_up(defn: pkg3.Definition[None, t3.Type[None]]) -> pkg4.Definiti
)
+@overload
def v3_to_v4(
dist: dist3.Library,
*,
package_version: str | None = None,
drop_input_attributes: bool = False,
-) -> Ok[dist4.LibraryDistribution]:
- """Migrate a format-3 Library to a v4 Library.
+) -> Ok[dist4.LibraryDistribution]: ...
+@overload
+def v3_to_v4(
+ dist: dist3.Specs,
+ *,
+ package_version: str | None = None,
+ drop_input_attributes: bool = False,
+) -> Ok[dist4.SpecsDistribution]: ...
+def v3_to_v4(
+ dist: dist3.Distribution,
+ *,
+ package_version: str | None = None,
+ drop_input_attributes: bool = False,
+) -> Ok[dist4.LibraryDistribution] | Ok[dist4.SpecsDistribution]:
+ """Migrate a format-3 Library or Specs to the same v4 distribution kind.
Unit type attributes become empty `TypeAttributes`; each value attribute type
becomes ``inferred_type``; ``WholeNumberLiteral`` becomes ``IntegerLiteral``.
+ A Specs maps its package specification as a Library maps its dependencies.
Args:
- dist: The v3 Library.
+ dist: The v3 Library or Specs.
package_version: Optional release version for the v4 package identity.
Without it the version is absent; nothing is invented.
drop_input_attributes: Reset each value-definition input's attributes to
@@ -360,15 +376,26 @@ def v3_to_v4(
be encoded as v4 JSON. Each input that loses information is reported.
Returns:
- ``Ok`` with the v4 Library and any ``input_attributes_dropped`` warnings.
- Forward migration has no failure paths.
+ ``Ok`` with the v4 Library or Specs and any ``input_attributes_dropped``
+ warnings. Forward migration has no failure paths.
"""
+ package = dist4.PackageInfo(_path_up(dist.package_name), package_version)
+ dependencies = {
+ _path_up(p): _package_spec_up(s) for p, s in dist.dependencies.items()
+ }
+ if isinstance(dist, dist3.Specs):
+ specs = dist4.SpecsDistribution(
+ package=package,
+ specification=_package_spec_up(dist.specification),
+ dependencies=dependencies,
+ )
+ if drop_input_attributes:
+ return _drop_input_attributes(specs)
+ return Ok(specs)
library = dist4.LibraryDistribution(
- package=dist4.PackageInfo(_path_up(dist.package_name), package_version),
+ package=package,
definition=_package_def_up(dist.definition),
- dependencies={
- _path_up(p): _package_spec_up(s) for p, s in dist.dependencies.items()
- },
+ dependencies=dependencies,
)
if drop_input_attributes:
return _drop_input_attributes(library)
@@ -1136,12 +1163,25 @@ def package_def(
)
+@overload
+def v4_to_v3(
+ dist: dist4.LibraryDistribution, *, drop_source_locations: bool = False
+) -> MigrationResult[dist3.Library]: ...
+@overload
+def v4_to_v3(
+ dist: dist4.SpecsDistribution, *, drop_source_locations: bool = False
+) -> MigrationResult[dist3.Specs]: ...
+@overload
+def v4_to_v3(
+ dist: dist4.ApplicationDistribution, *, drop_source_locations: bool = False
+) -> Err: ...
def v4_to_v3(
dist: dist4.Distribution, *, drop_source_locations: bool = False
-) -> MigrationResult[dist3.Library]:
- """Migrate a v4 distribution to a format-3 Library, or report why it cannot.
+) -> MigrationResult[dist3.Library] | MigrationResult[dist3.Specs]:
+ """Migrate a v4 Library or Specs to format 3, or report why it cannot.
- Every unsupported construct is collected in one pass.
+ Every unsupported construct is collected in one pass. A Specs becomes a v3
+ Specs, which format 3.1.0 introduced; an Application has no format-3 form.
Args:
dist: The v4 distribution.
@@ -1149,39 +1189,46 @@ def v4_to_v3(
This is the only opt-in loss.
Returns:
- ``Ok`` with the v3 Library and any warnings, or ``Err`` with every
- diagnostic found.
+ ``Ok`` with the v3 Library or Specs and any warnings, or ``Err`` with
+ every diagnostic found.
"""
- if not isinstance(dist, dist4.LibraryDistribution):
- kind = "Specs" if isinstance(dist, dist4.SpecsDistribution) else "Application"
- detail = (
- "; a v3 Specs distribution needs IR 3.1.0 "
- "(https://github.com/finos/morphir-python/issues/28)"
- if kind == "Specs"
- else ""
- )
+ if isinstance(dist, dist4.ApplicationDistribution):
return Err(
(
Diagnostic(
d.UNSUPPORTED_V4_DOWNGRADE,
- kind,
+ "Application",
("distribution",),
- f"{kind} distributions cannot be written as format 3.0.0{detail}",
+ "Application distributions cannot be written as format 3, "
+ "which has no Application",
),
)
)
job = _Downgrade(drop_source_locations)
loc: Location = ("distribution",)
- library = dist3.Library(
- package_name=job.path(dist.package.name, (*loc, "package")),
- definition=job.package_def(dist.definition, (*loc, "modules")),
- dependencies={
+
+ def dependencies() -> dict[path3.Path, pkg3.Specification[None]]:
+ return {
job.path(p, loc): job.package_spec(
s, (*loc, "dependencies", path4.to_string(p))
)
for p, s in dist.dependencies.items()
- },
- )
+ }
+
+ # Fields are migrated in declaration order, so diagnostics keep that order.
+ result: dist3.Library | dist3.Specs
+ if isinstance(dist, dist4.SpecsDistribution):
+ result = dist3.Specs(
+ package_name=job.path(dist.package.name, (*loc, "package")),
+ specification=job.package_spec(dist.specification, (*loc, "modules")),
+ dependencies=dependencies(),
+ )
+ else:
+ result = dist3.Library(
+ package_name=job.path(dist.package.name, (*loc, "package")),
+ definition=job.package_def(dist.definition, (*loc, "modules")),
+ dependencies=dependencies(),
+ )
if job.errors:
return Err(tuple(job.errors))
warnings: list[Diagnostic] = []
@@ -1191,7 +1238,7 @@ def v4_to_v3(
d.PACKAGE_VERSION_DROPPED,
"PackageInfo",
(*loc, "package", "version"),
- f"package version {dist.package.version!r} was dropped; format 3.0.0 "
+ f"package version {dist.package.version!r} was dropped; format 3 "
"has no package version",
)
)
@@ -1204,7 +1251,10 @@ def v4_to_v3(
f"{job.dropped_sources} value source location(s) were dropped",
)
)
- return Ok(library, tuple(warnings))
+ # Ok is invariant, so each kind is returned under its own narrowed type.
+ if isinstance(result, dist3.Specs):
+ return Ok(result, tuple(warnings))
+ return Ok(result, tuple(warnings))
__all__ = [
diff --git a/packages/morphir/src/morphir/ir/v3/__init__.py b/packages/morphir/src/morphir/ir/v3/__init__.py
index ffc86091..87d7f0ac 100644
--- a/packages/morphir/src/morphir/ir/v3/__init__.py
+++ b/packages/morphir/src/morphir/ir/v3/__init__.py
@@ -1,8 +1,9 @@
-"""Morphir IR format 3.0.0, the format morphir-elm writes today.
+"""Morphir IR format 3, the format morphir-elm writes today, and its 3.1.0 revision.
-morphir-elm (commit bc99af69) is normative for the model and its JSON shapes. The
-classes here are distinct from `morphir.ir.v4`; use `morphir.ir.migrate` to move
-between versions.
+morphir-elm (commit bc99af69) is normative for the 3.0.0 model and its JSON
+shapes. What 3.1.0 adds, the `Specs` distribution, follows the finos/morphir v3
+specification (decision record 0018). The classes here are distinct from
+`morphir.ir.v4`; use `morphir.ir.migrate` to move between versions.
"""
from . import (
@@ -21,7 +22,7 @@
value,
)
from .access_controlled import Access, AccessControlled
-from .distribution import Distribution, Library
+from .distribution import Distribution, Library, Specs
from .documented import Documented
from .fqname import FQName
from .name import Name
@@ -38,6 +39,7 @@
"Name",
"Path",
"QName",
+ "Specs",
"access_controlled",
"codec",
"distribution",
diff --git a/packages/morphir/src/morphir/ir/v3/codec.py b/packages/morphir/src/morphir/ir/v3/codec.py
index ae637399..94843788 100644
--- a/packages/morphir/src/morphir/ir/v3/codec.py
+++ b/packages/morphir/src/morphir/ir/v3/codec.py
@@ -1,13 +1,15 @@
-"""JSON codecs for Morphir IR format 3.0.0.
+"""JSON codecs for Morphir IR format 3 (3.0.0 and 3.1.0).
Each section mirrors one morphir-elm codec module at commit bc99af69
-(``src/Morphir/IR/*/Codec.elm``). As in elm, node codecs take attribute codecs as
+(``src/Morphir/IR/*/Codec.elm``). The ``Specs`` distribution of format 3.1.0,
+which elm does not have, follows the finos/morphir v3 specification
+(``docs/spec/ir/schemas/v3/whats-new.md``) and morphir-rust commit cd9f7fb. As in elm, node codecs take attribute codecs as
parameters, so untyped fragments such as ``["Literal", {}, ["WholeNumberLiteral",
42]]`` decode with `decode_unit`. Dictionaries are written sorted by key, as elm's
``Dict.toList`` does.
-The only intended departure from elm is the ``formatVersion`` reader, which is
-`morphir.ir.format_version` (elm accepts only an integer).
+The only intended departure from elm for 3.0.0 content is the ``formatVersion``
+reader, which is `morphir.ir.format_version` (elm accepts only an integer).
Decoders raise `DiagnosticError` with a location path from the decoded value.
"""
@@ -20,13 +22,20 @@
from .. import diagnostics as d
from ..diagnostics import DiagnosticError, Location, fail
-from ..format_version import V3, V3_TABLE, read_document, write_format_version
+from ..format_version import (
+ V3,
+ V3_1,
+ V3_TABLE,
+ FormatVersion,
+ read_document,
+ write_format_version,
+)
from . import literal as lit
from . import module, package
from . import type as t
from . import value as v
from .access_controlled import Access, AccessControlled
-from .distribution import Library
+from .distribution import Distribution, Library, Specs
from .documented import Documented
from .fqname import FQName
from .name import Name
@@ -1063,35 +1072,101 @@ def decode(j: object, loc: Location = ()) -> package.Definition[TA, VA]:
_decode_library_def = decode_package_definition(decode_unit, _decode_typed_type)
-def encode_distribution(dist: Library) -> Json:
- """Encode a distribution as elm's ``["Library", name, deps, def]``."""
- return [
- "Library",
- encode_path(dist.package_name),
- _sorted_pairs(dist.dependencies, encode_path, _encode_dependency),
- _encode_library_def(dist.definition),
- ]
+_encode_specs_def = encode_package_specification(encode_unit)
+_SPECS_MODULE_MEMBERS = frozenset({"types", "values", "doc"})
+
+
+def _decode_specs_module(j: object, loc: Location = ()) -> module.Specification[None]:
+ """Decode one of a Specs distribution's own modules, refusing other members.
+
+ The elm-faithful module-specification decoder ignores unknown members, so a
+ module definition would read as a broken specification. As in morphir-rust
+ ``SpecsModuleEntry``, only ``types``, ``values`` and ``doc`` are accepted here.
+ """
+ obj = _expect_object(j, loc, "ModuleSpecification")
+ for key in obj:
+ if key not in _SPECS_MODULE_MEMBERS:
+ raise fail(
+ d.UNKNOWN_MEMBER,
+ "ModuleSpecification",
+ (*loc, key),
+ "a v3 Specs distribution holds module specifications, not "
+ f"definitions: unexpected member {key!r}",
+ )
+ return _decode_dependency_module(obj, loc)
+
+
+_decode_dependency_module = decode_module_specification(decode_unit)
+
+
+def _decode_specs_def(j: object, loc: Location = ()) -> package.Specification[None]:
+ obj = _expect_object(j, loc, "PackageSpecification")
+ return package.Specification(
+ _pair_list_to_dict(
+ decode_path,
+ _decode_specs_module,
+ _field(obj, "modules", loc, "PackageSpecification"),
+ (*loc, "modules"),
+ "Modules",
+ )
+ )
+
+def encode_distribution(dist: Distribution) -> Json:
+ """Encode a distribution as ``[kind, name, deps, def or spec]``.
-def decode_distribution(j: object, loc: Location = ()) -> Library:
- """Decode a distribution; only the ``Library`` tag exists in format 3.0.0."""
+ A `Library` is elm's ``["Library", name, deps, def]``; a `Specs` is
+ ``["Specs", name, deps, spec]``.
+ """
+ match dist:
+ case Library():
+ return [
+ "Library",
+ encode_path(dist.package_name),
+ _sorted_pairs(dist.dependencies, encode_path, _encode_dependency),
+ _encode_library_def(dist.definition),
+ ]
+ case Specs():
+ return [
+ "Specs",
+ encode_path(dist.package_name),
+ _sorted_pairs(dist.dependencies, encode_path, _encode_dependency),
+ _encode_specs_def(dist.specification),
+ ]
+
+
+def decode_distribution(j: object, loc: Location = ()) -> Distribution:
+ """Decode a ``Library`` or, from format 3.1.0, a ``Specs`` distribution.
+
+ This decoder does not see ``formatVersion``; `decode_versioned_distribution`
+ refuses a ``Specs`` declared below 3.1.0.
+ """
tag, items = _tag(j, loc, "Distribution")
- if tag != "Library":
+ if tag not in ("Library", "Specs"):
raise fail(
d.UNKNOWN_NODE,
"Distribution",
(*loc, 0),
- f"unknown distribution kind {tag!r}; format 3.0.0 has only Library",
+ f"unknown distribution kind {tag!r}; format 3 has Library and, "
+ "from 3.1.0, Specs",
)
def at(i: int) -> tuple[object, Location]:
return _index(items, i, loc, tag), (*loc, i)
+ package_name = decode_path(*at(1))
+ dependencies = _pair_list_to_dict(
+ decode_path, _decode_dependency, *at(2), "Dependencies"
+ )
+ if tag == "Specs":
+ return Specs(
+ package_name=package_name,
+ specification=_decode_specs_def(*at(3)),
+ dependencies=dependencies,
+ )
return Library(
- package_name=decode_path(*at(1)),
- dependencies=_pair_list_to_dict(
- decode_path, _decode_dependency, *at(2), "Dependencies"
- ),
+ package_name=package_name,
+ dependencies=dependencies,
definition=_decode_library_def(*at(3)),
)
@@ -1099,19 +1174,34 @@ def at(i: int) -> tuple[object, Location]:
# --- FormatVersion ---
-def encode_versioned_distribution(dist: Library) -> dict[str, Json]:
- """Encode ``{"formatVersion": 3, "distribution": ...}``, version first."""
+def format_version_of(dist: Distribution) -> FormatVersion:
+ """Return the lowest release that expresses ``dist``.
+
+ A `Library` needs only 3.0.0; a `Specs` needs 3.1.0, the release that added
+ it (finos/morphir decision record 0018).
+ """
+ return V3_1 if isinstance(dist, Specs) else V3
+
+
+def encode_versioned_distribution(dist: Distribution) -> dict[str, Json]:
+ """Encode ``{"formatVersion": ..., "distribution": ...}``, version first.
+
+ The version is the lowest one that fits the content: ``3`` for a `Library`,
+ ``"3.1.0"`` for a `Specs`, whatever version the input declared.
+ """
return {
- "formatVersion": write_format_version(V3),
+ "formatVersion": write_format_version(format_version_of(dist)),
"distribution": encode_distribution(dist),
}
-def decode_versioned_distribution(document: str | object) -> Library:
+def decode_versioned_distribution(document: str | object) -> Distribution:
"""Decode a format-3 ``morphir-ir.json`` document.
The format version is read with the shared permissive reader and must be in
- ``[3.0.0,3.2.0)``; ``4`` fails with ``unsupported_format_version_major``.
+ ``[3.0.0,3.2.0)``; ``4`` fails with ``unsupported_format_version_major``. A
+ ``Specs`` distribution declared below 3.1.0 fails with ``specs_before_3_1``
+ before its body is decoded.
Args:
document: JSON text or an already-decoded JSON value.
@@ -1119,11 +1209,20 @@ def decode_versioned_distribution(document: str | object) -> Library:
Raises:
DiagnosticError: On a format-version or decoding failure.
"""
- _, root = read_document(document, V3_TABLE)
+ version, root = read_document(document, V3_TABLE)
obj = _expect_object(root, (), "document")
- return decode_distribution(
- _field(obj, "distribution", (), "document"), ("distribution",)
- )
+ distribution = _field(obj, "distribution", (), "document")
+ loc: Location = ("distribution",)
+ tag, _ = _tag(distribution, loc, "Distribution")
+ if tag == "Specs" and version < V3_1:
+ raise fail(
+ d.SPECS_BEFORE_3_1,
+ "Specs",
+ ("formatVersion",),
+ f"a v3 Specs distribution needs formatVersion {V3_1} or later, "
+ f"found {obj['formatVersion']!r}",
+ )
+ return decode_distribution(distribution, loc)
__all__ = [
@@ -1172,4 +1271,5 @@ def decode_versioned_distribution(document: str | object) -> Library:
"encode_value_definition",
"encode_value_specification",
"encode_versioned_distribution",
+ "format_version_of",
]
diff --git a/packages/morphir/src/morphir/ir/v3/distribution.py b/packages/morphir/src/morphir/ir/v3/distribution.py
index f186edf5..72f1d2db 100644
--- a/packages/morphir/src/morphir/ir/v3/distribution.py
+++ b/packages/morphir/src/morphir/ir/v3/distribution.py
@@ -1,8 +1,9 @@
"""V3 distributions (morphir-elm ``Morphir.IR.Distribution``).
-Format 3.0.0 has only the ``Library`` distribution. Type attributes are unit
-(``None``) and value attributes are the value's type. There is no package release
-version.
+Format 3.0.0 has only the ``Library`` distribution. Format 3.1.0 adds ``Specs``,
+which publishes a package's specification without its definitions (finos/morphir
+decision record 0018). Type attributes are unit (``None``) and value attributes
+are the value's type. There is no package release version.
"""
from __future__ import annotations
@@ -25,4 +26,17 @@ class Library:
dependencies: dict[Path, package.Specification[None]] = field(default_factory=dict)
-type Distribution = Library
+@dataclass(frozen=True)
+class Specs:
+ """A format-3.1.0 Specs distribution: a package's public face.
+
+ It has the shape of a `Library`, with a package specification in place of the
+ package definition. Writers declare ``formatVersion`` ``"3.1.0"`` for it.
+ """
+
+ package_name: Path
+ specification: package.Specification[None]
+ dependencies: dict[Path, package.Specification[None]] = field(default_factory=dict)
+
+
+type Distribution = Library | Specs
diff --git a/tests/fixtures/ir/v3/PROVENANCE.md b/tests/fixtures/ir/v3/PROVENANCE.md
index 4f34f108..e36f130f 100644
--- a/tests/fixtures/ir/v3/PROVENANCE.md
+++ b/tests/fixtures/ir/v3/PROVENANCE.md
@@ -1,6 +1,6 @@
# Format-3 golden fixtures
-These files are Morphir IR format 3 (`"formatVersion": 3`) as written by
+The files in the first table are Morphir IR format 3 (`"formatVersion": 3`) as written by
morphir-elm or published by finos/morphir as v3 examples. They are copied
byte-for-byte and are not edited. Tests read them directly; morphir-elm is not a
test-time dependency.
@@ -21,3 +21,17 @@ Copyright 2020 Morgan Stanley and the FINOS Morphir contributors.
| `finos-rule-set-example.json` | finos/morphir | `efa36060` | `website/static/ir/examples/v3/rule-set-example.json` |
The rentals example is format 2 and is deliberately not included.
+
+## Format 3.1.0 fixtures
+
+These files are IR format 3.1.0 (`"formatVersion": "3.1.0"`), each holding a v3
+`Specs` distribution (finos/morphir decision record 0018). Upstream they are
+inline strings, not files: each fixture is the exact text of that string, with a
+trailing newline added, and is not otherwise edited.
+
+Both source repositories are licensed under the Apache License, Version 2.0.
+
+| Fixture | Repository | Commit | Source |
+| --- | --- | --- | --- |
+| `mck-distributions-0011-specs.json` | [finos/morphir](https://github.com/finos/morphir) | `efa36060` | `spec/ir/mck/distributions.feature`, scenario distributions-0011, its "canonical JSON spelling" |
+| `rust-classic-specs-transport.json` | [finos/morphir-rust](https://github.com/finos/morphir-rust) | `cd9f7fb` | `crates/morphir-common/tests/classic_specs_transport.rs`, the `SPECS` constant |
diff --git a/tests/fixtures/ir/v3/mck-distributions-0011-specs.json b/tests/fixtures/ir/v3/mck-distributions-0011-specs.json
new file mode 100644
index 00000000..78b48bd0
--- /dev/null
+++ b/tests/fixtures/ir/v3/mck-distributions-0011-specs.json
@@ -0,0 +1 @@
+{ "formatVersion": "3.1.0", "distribution": ["Specs", [["my"], ["pkg"]], [], { "modules": [[[["basics"]], { "types": [[["int"], { "doc": "", "value": ["OpaqueTypeSpecification", []] }]], "values": [], "doc": "Basics." }]] }] }
diff --git a/tests/fixtures/ir/v3/rust-classic-specs-transport.json b/tests/fixtures/ir/v3/rust-classic-specs-transport.json
new file mode 100644
index 00000000..8c603573
--- /dev/null
+++ b/tests/fixtures/ir/v3/rust-classic-specs-transport.json
@@ -0,0 +1 @@
+{"formatVersion":"3.1.0","distribution":["Specs",[["my"],["pkg"]],[[[["morphir"],["s","d","k"]],{"modules":[]}]],{"modules":[[[["basics"]],{"types":[[["int"],{"doc":"","value":["OpaqueTypeSpecification",[]]}]],"values":[],"doc":"Basics."}]]}]}
diff --git a/tests/tools/test_cli.py b/tests/tools/test_cli.py
index fd49289b..fa876037 100644
--- a/tests/tools/test_cli.py
+++ b/tests/tools/test_cli.py
@@ -95,7 +95,7 @@ def test_unreadable_file(self, tmp_path: Path) -> None:
assert "cannot read" in result.stderr
def test_same_codes_as_library(self, tmp_path: Path) -> None:
- doc = {"formatVersion": 3, "distribution": ["Specs", [], [], {}]}
+ doc = {"formatVersion": 3, "distribution": ["Application", [], [], {}]}
result = run("ir", "inspect", write(tmp_path, "x.json", doc))
assert result.exit_code == 1
assert d.UNKNOWN_NODE in result.stderr
@@ -148,7 +148,10 @@ def test_same_version_rewrites_canonically(
assert run("ir", "convert", v4_file, "--to", "v4", "-o", out).exit_code == 0
def test_downgrade_failure_leaves_no_output(self, tmp_path: Path) -> None:
- doc = {"formatVersion": 4, "distribution": {"Specs": {"packageName": "a"}}}
+ doc = {
+ "formatVersion": 4,
+ "distribution": {"Application": {"packageName": "a", "entryPoints": {}}},
+ }
out = tmp_path / "out.json"
result = run(
"ir", "convert", write(tmp_path, "x.json", doc), "--to", "v3", "-o", out
diff --git a/tests/unit/ir/test_migrate.py b/tests/unit/ir/test_migrate.py
index 18f1c5ca..749618c6 100644
--- a/tests/unit/ir/test_migrate.py
+++ b/tests/unit/ir/test_migrate.py
@@ -41,10 +41,16 @@
FIXTURE_FILES = sorted(FIXTURES.glob("*.json"))
-def load(path: Path) -> v3.Library:
+def load(path: Path) -> v3.Distribution:
return v3_codec.decode_versioned_distribution(path.read_text(encoding="utf-8"))
+def load_library(path: Path) -> v3.Library:
+ dist = load(path)
+ assert isinstance(dist, v3.Library)
+ return dist
+
+
def n(*words: str) -> Name:
return Name(words)
@@ -94,7 +100,7 @@ def test_round_trip_is_semantically_equal(self, path: Path) -> None:
assert [w.code for w in back.warnings] == [d.PACKAGE_VERSION_DROPPED]
def test_values_carry_inferred_type(self) -> None:
- lib = load(FIXTURES / "elm-simpleValueTree.json")
+ lib = load_library(FIXTURES / "elm-simpleValueTree.json")
result = v3_to_v4(lib)
assert isinstance(result, Ok)
assert result.value.package.version is None
@@ -149,20 +155,13 @@ def test_package_version_dropped_warning(self) -> None:
assert isinstance(result, Ok)
assert [w.code for w in result.warnings] == [d.PACKAGE_VERSION_DROPPED]
- @pytest.mark.parametrize(
- ("dist", "kind"),
- [
- (SpecsDistribution(PackageInfo(PKG), pkg4.Specification()), "Specs"),
- (
- ApplicationDistribution(PackageInfo(PKG), pkg4.Definition()),
- "Application",
- ),
- ],
- )
- def test_specs_and_application_do_not_downgrade(
- self, dist: object, kind: str
- ) -> None:
- assert errors(v4_to_v3(dist)) == [(d.UNSUPPORTED_V4_DOWNGRADE, kind)] # type: ignore[arg-type]
+ def test_application_does_not_downgrade(self) -> None:
+ dist = ApplicationDistribution(PackageInfo(PKG), pkg4.Definition())
+ result = v4_to_v3(dist)
+ assert errors(result) == [(d.UNSUPPORTED_V4_DOWNGRADE, "Application")]
+ assert isinstance(result, Err)
+ assert "issues/28" not in result.diagnostics[0].message
+ assert "format 3" in result.diagnostics[0].message
@pytest.mark.parametrize(
"node",
@@ -419,3 +418,85 @@ def test_default_upgrade_keeps_input_attributes(self) -> None:
back = v4_to_v3(result.value)
assert isinstance(back, Ok)
assert back.value == lib
+
+
+SDK = IRPath((n("morphir"), n("s", "d", "k")))
+
+
+def module_spec(
+ type_doc: str | None = "", value_doc: str | None = ""
+) -> mod4.Specification:
+ return mod4.Specification(
+ types={
+ n("int"): Documented(type_doc, ts4.OpaqueTypeSpecification([])),
+ },
+ values={
+ n("inc"): Documented(value_doc, v4.Specification([(n("x"), INT)], INT)),
+ },
+ doc="Basics.",
+ )
+
+
+def specs(
+ *, version: str | None = None, spec: mod4.Specification | None = None
+) -> SpecsDistribution:
+ return SpecsDistribution(
+ PackageInfo(PKG, version),
+ pkg4.Specification({IRPath((n("basics"),)): spec or module_spec()}),
+ {SDK: pkg4.Specification()},
+ )
+
+
+class TestSpecs:
+ def test_v3_specs_migrates_forward(self) -> None:
+ dist = load(FIXTURES / "rust-classic-specs-transport.json")
+ assert isinstance(dist, v3.Specs)
+ result = v3_to_v4(dist, package_version="2.0.0")
+ assert isinstance(result, Ok)
+ assert result.warnings == ()
+ up = result.value
+ assert isinstance(up, SpecsDistribution)
+ assert up.package == PackageInfo(PKG, "2.0.0")
+ assert up.dependencies == {SDK: pkg4.Specification()}
+ basics = up.specification.modules[IRPath((n("basics"),))]
+ assert basics.doc == "Basics."
+ assert basics.types == {
+ n("int"): Documented("", ts4.OpaqueTypeSpecification([]))
+ }
+
+ def test_compatible_v4_specs_downgrades(self) -> None:
+ result = v4_to_v3(specs())
+ assert isinstance(result, Ok)
+ assert result.warnings == ()
+ down = result.value
+ assert isinstance(down, v3.Specs)
+ assert down.package_name == v3.Path((v3.Name(("my",)), v3.Name(("pkg",))))
+ assert list(down.dependencies.values()) == [pkg3.Specification()]
+ basics = down.specification.modules[v3.Path((v3.Name(("basics",)),))]
+ assert set(basics.values) == {v3.Name(("inc",))}
+
+ def test_v4_specs_version_is_dropped_with_a_warning(self) -> None:
+ result = v4_to_v3(specs(version="1.2.0"))
+ assert isinstance(result, Ok)
+ assert [w.code for w in result.warnings] == [d.PACKAGE_VERSION_DROPPED]
+
+ def test_incompatible_v4_specs_is_reported(self) -> None:
+ result = v4_to_v3(specs(spec=module_spec(type_doc=None, value_doc=None)))
+ assert errors(result) == [
+ (d.UNSUPPORTED_DOCUMENTATION_DOWNGRADE, "TypeSpecification"),
+ (d.UNSUPPORTED_DOCUMENTATION_DOWNGRADE, "ValueSpecification"),
+ ]
+ assert isinstance(result, Err)
+ assert result.diagnostics[0].location == (
+ "distribution",
+ "modules",
+ "basics",
+ "types",
+ "int",
+ )
+
+ def test_v4_specs_round_trips_through_v3(self) -> None:
+ down = v4_to_v3(specs())
+ assert isinstance(down, Ok)
+ up = v3_to_v4(down.value)
+ assert up.value == specs()
diff --git a/tests/unit/ir/test_v3_codec.py b/tests/unit/ir/test_v3_codec.py
index d916696b..cd8807b4 100644
--- a/tests/unit/ir/test_v3_codec.py
+++ b/tests/unit/ir/test_v3_codec.py
@@ -25,6 +25,8 @@
FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" / "ir" / "v3"
FIXTURE_FILES = sorted(FIXTURES.glob("*.json"))
+KIT_SPECS = FIXTURES / "mck-distributions-0011-specs.json"
+RUST_SPECS = FIXTURES / "rust-classic-specs-transport.json"
INT = t.Reference(None, fqn("Morphir.SDK", "Basics", "Int"))
@@ -298,17 +300,20 @@ class TestVersionedDistribution:
@pytest.mark.parametrize("path", FIXTURE_FILES, ids=lambda p: p.name)
def test_golden_fixture_round_trips(self, path: Path) -> None:
text = path.read_text(encoding="utf-8")
- lib = codec.decode_versioned_distribution(text)
- encoded = codec.encode_versioned_distribution(lib)
+ dist = codec.decode_versioned_distribution(text)
+ encoded = codec.encode_versioned_distribution(dist)
assert next(iter(encoded)) == "formatVersion"
- assert encoded["formatVersion"] == 3
- assert codec.decode_versioned_distribution(json.dumps(encoded)) == lib
+ assert encoded["formatVersion"] == (
+ 3 if isinstance(dist, v3.Library) else "3.1.0"
+ )
+ assert codec.decode_versioned_distribution(json.dumps(encoded)) == dist
assert encoded == json.loads(text)
def test_fixture_is_inspectable_without_migration(self) -> None:
lib = codec.decode_versioned_distribution(
(FIXTURES / "elm-simpleValueTree.json").read_text()
)
+ assert isinstance(lib, v3.Library)
assert isinstance(lib.package_name, IRPath)
for mod in lib.definition.modules.values():
for value in mod.value.values.values():
@@ -331,10 +336,13 @@ def test_v4_is_refused(self) -> None:
assert "only 3.0.x and 3.1.x" in str(err)
def test_unknown_distribution_tag(self) -> None:
- doc = {"formatVersion": 3, "distribution": ["Specs", [], [], {}]}
+ doc = {"formatVersion": 3, "distribution": ["Application", [], [], {}]}
err = decode_error(codec.decode_versioned_distribution, doc)
assert err.codes == (d.UNKNOWN_NODE,)
- assert "'Specs'" in str(err)
+ assert err.diagnostics[0].location == ("distribution", 0)
+ assert "'Application'" in str(err)
+ assert "Library" in str(err)
+ assert "Specs" in str(err)
def test_missing_distribution(self) -> None:
err = decode_error(codec.decode_versioned_distribution, {"formatVersion": 3})
@@ -342,3 +350,115 @@ def test_missing_distribution(self) -> None:
def test_fqname_type(self) -> None:
assert isinstance(fqn("a", "b", "c"), FQName)
+
+
+def specs_doc(version: object, modules: object = None) -> dict[str, object]:
+ return {
+ "formatVersion": version,
+ "distribution": [
+ "Specs",
+ [["my"], ["pkg"]],
+ [],
+ {"modules": [] if modules is None else modules},
+ ],
+ }
+
+
+class TestSpecs:
+ def test_kit_case_decodes(self) -> None:
+ dist = codec.decode_versioned_distribution(KIT_SPECS.read_text())
+ assert isinstance(dist, v3.Specs)
+ assert dist.package_name == path_from_string("my.pkg")
+ assert dist.dependencies == {}
+ basics = dist.specification.modules[path_from_string("basics")]
+ assert basics.doc == "Basics."
+ assert basics.types[n("int")] == Documented("", t.OpaqueTypeSpecification(()))
+ assert basics.values == {}
+
+ def test_kit_case_encodes_to_its_canonical_json(self) -> None:
+ dist = codec.decode_versioned_distribution(KIT_SPECS.read_text())
+ assert codec.encode_versioned_distribution(dist) == json.loads(
+ KIT_SPECS.read_text()
+ )
+
+ def test_dependencies_decode_as_for_a_library(self) -> None:
+ dist = codec.decode_versioned_distribution(RUST_SPECS.read_text())
+ assert isinstance(dist, v3.Specs)
+ assert dist.dependencies == {
+ IRPath((Name(("morphir",)), Name(("s", "d", "k")))): (
+ package.Specification()
+ )
+ }
+
+ def test_specs_is_written_as_3_1_0(self) -> None:
+ dist = v3.Specs(path_from_string("my.pkg"), package.Specification())
+ encoded = codec.encode_versioned_distribution(dist)
+ assert encoded == specs_doc("3.1.0")
+ assert list(encoded) == ["formatVersion", "distribution"]
+
+ @pytest.mark.parametrize("version", ["3.1.0", "3.1.7"])
+ def test_specs_reads_at_every_3_1_release(self, version: str) -> None:
+ dist = codec.decode_versioned_distribution(specs_doc(version))
+ assert isinstance(dist, v3.Specs)
+
+ @pytest.mark.parametrize("version", [3, "3", "3.0.0", "3.0.1"])
+ def test_specs_before_3_1_is_refused(self, version: object) -> None:
+ err = decode_error(codec.decode_versioned_distribution, specs_doc(version))
+ assert err.codes == (d.SPECS_BEFORE_3_1,)
+ diagnostic = err.diagnostics[0]
+ assert diagnostic.construct == "Specs"
+ assert diagnostic.location == ("formatVersion",)
+ assert "3.1.0 or later" in diagnostic.message
+
+ def test_specs_version_is_checked_before_the_body(self) -> None:
+ doc = specs_doc(3, modules="not a list")
+ err = decode_error(codec.decode_versioned_distribution, doc)
+ assert err.codes == (d.SPECS_BEFORE_3_1,)
+
+ @pytest.mark.parametrize("version", [3, "3.0.0", "3.1.0"])
+ def test_library_reads_at_every_supported_3_x(self, version: object) -> None:
+ doc = json.loads((FIXTURES / "elm-base.json").read_text())
+ doc["formatVersion"] = version
+ dist = codec.decode_versioned_distribution(doc)
+ assert isinstance(dist, v3.Library)
+ assert codec.encode_versioned_distribution(dist)["formatVersion"] == 3
+
+ def test_module_definition_in_specs_is_refused(self) -> None:
+ definition = {
+ "access": "Public",
+ "value": {"types": [], "values": [], "doc": "Basics."},
+ }
+ doc = specs_doc("3.1.0", [[[["basics"]], definition]])
+ err = decode_error(codec.decode_versioned_distribution, doc)
+ assert d.UNKNOWN_MEMBER in err.codes
+ diagnostic = err.diagnostics[0]
+ assert diagnostic.code == d.UNKNOWN_MEMBER
+ assert diagnostic.location == ("distribution", 3, "modules", 0, 1, "access")
+ assert "specifications, not definitions" in diagnostic.message
+
+ def test_extra_member_in_specs_module_is_refused(self) -> None:
+ spec = {"types": [], "values": [], "doc": None, "extra": 1}
+ doc = specs_doc("3.1.0", [[[["basics"]], spec]])
+ err = decode_error(codec.decode_versioned_distribution, doc)
+ assert err.codes == (d.UNKNOWN_MEMBER,)
+
+ def test_specs_module_needs_types_and_values(self) -> None:
+ doc = specs_doc("3.1.0", [[[["basics"]], {"values": []}]])
+ err = decode_error(codec.decode_versioned_distribution, doc)
+ assert err.codes == (d.MISSING_MEMBER,)
+
+ def test_specs_module_must_be_an_object(self) -> None:
+ doc = specs_doc("3.1.0", [[[["basics"]], []]])
+ err = decode_error(codec.decode_versioned_distribution, doc)
+ assert err.codes == (d.INVALID_JSON,)
+
+ def test_dependency_modules_keep_the_elm_reading(self) -> None:
+ doc = json.loads(RUST_SPECS.read_text())
+ dependency = {"types": [], "values": [], "doc": None, "extra": 1}
+ doc["distribution"][2][0][1]["modules"] = [[[["basics"]], dependency]]
+ dist = codec.decode_versioned_distribution(doc)
+ assert isinstance(dist, v3.Specs)
+
+ def test_unversioned_specs_round_trips(self) -> None:
+ dist = codec.decode_versioned_distribution(RUST_SPECS.read_text())
+ assert codec.decode_distribution(codec.encode_distribution(dist)) == dist
From e475c4c441862eb49d9da79202e6587341271707 Mon Sep 17 00:00:00 2001
From: Damian Reeves <957246+DamianReeves@users.noreply.github.com>
Date: Tue, 27 Oct 2026 02:07:03 -0500
Subject: [PATCH 4/7] feat(tools): inspect and convert v3 Specs distributions
of IR 3.1.0
ir inspect reports kind Specs for a v3 file, and ir convert moves a Specs
between format 3.1.0 and draft v4. A v3 Library is still written as
format 3. The package READMEs, AGENTS.md and the BDD scenarios describe
IR 3.1.0, specs_before_3_1 and the lowest-version writer, and state that
document trees stay out of scope (bead morphir-ukvn.8.17).
---
AGENTS.md | 3 +-
features/ir_interchange.feature | 39 +++++++++++-
features/steps/ir_interchange_steps.py | 6 ++
openspec/changes/add-ir-3-1-0/tasks.md | 10 ++--
packages/morphir-tools/README.md | 14 +++--
.../morphir-tools/src/morphir_tools/cli.py | 34 +++++++----
packages/morphir/README.md | 59 +++++++++++++++----
tests/tools/test_cli.py | 46 +++++++++++++++
tests/unit/ir/test_format_version.py | 10 ++++
9 files changed, 184 insertions(+), 37 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 41c629b5..d51188df 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,7 +23,8 @@ morphir-python/
**`morphir` (core library)**
- Morphir IR (Intermediate Representation) models, in two versions:
- `morphir.ir.v3` (format 3.0.0; morphir-elm is normative) and `morphir.ir.v4`
+ `morphir.ir.v3` (formats 3.0.0 and 3.1.0; morphir-elm is normative for 3.0.0,
+ finos/morphir for the 3.1.0 `Specs` distribution) and `morphir.ir.v4`
(draft upstream; `morphir.ir` re-exports it). Read `formatVersion` only through
`morphir.ir.format_version`, convert only through `morphir.ir.migrate`, and keep
diagnostic codes in `morphir.ir.diagnostics`. See
diff --git a/features/ir_interchange.feature b/features/ir_interchange.feature
index 223ddf19..c65372e4 100644
--- a/features/ir_interchange.feature
+++ b/features/ir_interchange.feature
@@ -1,7 +1,7 @@
Feature: IR interchange
As a Morphir tooling user
I want to inspect and convert versioned morphir-ir.json files
- So that I can move models between IR format 3.0.0 and draft IR v4
+ So that I can move models between IR format 3 (3.0.x and 3.1.x) and draft IR v4
Scenario: Inspect a morphir-elm format 3 file
Given the golden fixture "elm-simpleValueTree.json"
@@ -28,8 +28,41 @@ Feature: IR interchange
Then the command exits with code 1
And the errors contain "missing_format_version"
- Scenario: Specs cannot be downgraded
- Given a file with the JSON '{"formatVersion": 4, "distribution": {"Specs": {"packageName": "a"}}}'
+ Scenario: Inspect a v3 Specs distribution of IR 3.1.0
+ Given the golden fixture "mck-distributions-0011-specs.json"
+ When I run "ir inspect" on it
+ Then the command exits with code 0
+ And the output contains "format: 3.1.0"
+ And the output contains "kind: Specs"
+ And the output contains "package: my/pkg"
+
+ Scenario: Convert a v3 Specs distribution to draft v4 and back
+ Given the golden fixture "rust-classic-specs-transport.json"
+ When I convert it to "v4"
+ Then the command exits with code 0
+ And the converted file declares formatVersion 4
+ When I inspect the converted file
+ Then the output contains "kind: Specs"
+ When I convert the converted file to "v3"
+ Then the command exits with code 0
+ And the converted file declares formatVersion "3.1.0"
+ And the converted file has the same JSON as the fixture
+
+ Scenario: A Specs distribution declared as format 3 is refused
+ Given a file with the JSON '{"formatVersion": 3, "distribution": ["Specs", [["a"]], [], {"modules": []}]}'
+ When I run "ir inspect" on it
+ Then the command exits with code 1
+ And the errors contain "specs_before_3_1"
+
+ Scenario: A later 3.x minor is refused
+ Given a file with the JSON '{"formatVersion": "3.2.0", "distribution": []}'
+ When I run "ir inspect" on it
+ Then the command exits with code 1
+ And the errors contain "unsupported_format_version_minor"
+ And the errors contain "3.0.x, 3.1.x and draft 4.0.x"
+
+ Scenario: Application cannot be downgraded
+ Given a file with the JSON '{"formatVersion": 4, "distribution": {"Application": {"packageName": "a", "entryPoints": {}}}}'
When I convert it to "v3"
Then the command exits with code 1
And the errors contain "unsupported_v4_downgrade"
diff --git a/features/steps/ir_interchange_steps.py b/features/steps/ir_interchange_steps.py
index 5ccebeee..2e69beb3 100644
--- a/features/steps/ir_interchange_steps.py
+++ b/features/steps/ir_interchange_steps.py
@@ -102,6 +102,12 @@ def step_declares_version(context: Context, version: int) -> None:
assert json.loads(context.output.read_text())["formatVersion"] == version
+@then('the converted file declares formatVersion "{version}"')
+def step_declares_release(context: Context, version: str) -> None:
+ """Check the converted file's formatVersion release string."""
+ assert json.loads(context.output.read_text())["formatVersion"] == version
+
+
@then("the converted file has the same JSON as the fixture")
def step_same_json(context: Context) -> None:
"""Check the converted file matches the fixture JSON."""
diff --git a/openspec/changes/add-ir-3-1-0/tasks.md b/openspec/changes/add-ir-3-1-0/tasks.md
index 5d30c48b..22ac6c57 100644
--- a/openspec/changes/add-ir-3-1-0/tasks.md
+++ b/openspec/changes/add-ir-3-1-0/tasks.md
@@ -20,14 +20,14 @@
## 4. IR interchange CLI and docs for IR 3.1 (morphir-ukvn.8.13.4)
-- [ ] 4.1 Write failing CLI tests in `tests/tools/test_cli.py` and BDD scenarios in `features/ir_interchange.feature`: inspect a v3 `Specs` reports `format: 3.1.0` and `kind: Specs`; convert a v3 `Specs` to v4 and back; `"3.2.0"` is refused naming `3.0.x, 3.1.x and draft 4.0.x`; verify they fail
-- [ ] 4.2 Dispatch on the decoded v3 kind in `ir inspect` and `ir convert` and update the help text (design CLI-1); verify the CLI tests and `mise run test-bdd` pass
-- [ ] 4.3 Update `packages/morphir/README.md` ("Morphir IR versions") and `packages/morphir-tools/README.md` for 3.1.0, `Specs`, `specs_before_3_1`, the lowest-version writer and the document-tree scope; verify `tests/unit/ir/test_ir_docs.py` passes
+- [x] 4.1 Write failing CLI tests in `tests/tools/test_cli.py` and BDD scenarios in `features/ir_interchange.feature`: inspect a v3 `Specs` reports `format: 3.1.0` and `kind: Specs`; convert a v3 `Specs` to v4 and back; `"3.2.0"` is refused naming `3.0.x, 3.1.x and draft 4.0.x`; verify they fail
+- [x] 4.2 Dispatch on the decoded v3 kind in `ir inspect` and `ir convert` and update the help text (design CLI-1); verify the CLI tests and `mise run test-bdd` pass
+- [x] 4.3 Update `packages/morphir/README.md` ("Morphir IR versions") and `packages/morphir-tools/README.md` for 3.1.0, `Specs`, `specs_before_3_1`, the lowest-version writer and the document-tree scope; verify `tests/unit/ir/test_ir_docs.py` passes
## 5. Integration checks for IR 3.1 (morphir-ukvn.8.13.5)
-- [ ] 5.1 Run `mise run check` (lint, format check, mypy and pyright strict, tests, coverage at least 80%) and `mise run test-bdd`; verify both pass
-- [ ] 5.2 Run `openspec validate add-ir-3-1-0 --strict`; verify it reports the change as valid
+- [x] 5.1 Run `mise run check` (lint, format check, mypy and pyright strict, tests, coverage at least 80%) and `mise run test-bdd`; verify both pass
+- [x] 5.2 Run `openspec validate add-ir-3-1-0 --strict`; verify it reports the change as valid
## Workflow follow-up
diff --git a/packages/morphir-tools/README.md b/packages/morphir-tools/README.md
index d5fe7486..495cc08f 100644
--- a/packages/morphir-tools/README.md
+++ b/packages/morphir-tools/README.md
@@ -26,8 +26,9 @@ This will also install the `morphir` core library as a dependency.
## Usage
The `morphir-tools` command inspects and converts versioned `morphir-ir.json`
-files. It reads format 3.0.x (what morphir-elm writes) and draft IR 4.0.x, and
-uses the `morphir` library for every read, write, and migration.
+files. It reads format 3.0.x (what morphir-elm writes), format 3.1.x (which adds
+the v3 `Specs` distribution) and draft IR 4.0.x, and uses the `morphir` library
+for every read, write, and migration.
```bash
morphir-tools ir inspect morphir-ir.json
@@ -35,13 +36,16 @@ morphir-tools ir convert morphir-ir.json --to v4 -o out.json --package-version 1
morphir-tools ir convert v4.json --to v3 -o out.json --drop-source-locations
```
-`inspect` prints the normalized format version, the distribution kind, and the
-package name, and labels v4 as draft. `convert` writes through a temporary file
+`inspect` prints the normalized format version, the distribution kind
+(`Library`, `Specs` or `Application`), and the package name, and labels v4 as
+draft. `convert` handles `Library` and `Specs` in both directions. It writes a
+v3 `Library` as format `3` and a v3 `Specs` as `"3.1.0"`, the lowest version that
+holds each. `convert` writes through a temporary file
and renames it only on success, so a failed conversion leaves no output file.
Diagnostics go to standard error as `code at location: message`.
Exit codes are 0 for success (also with warnings), 1 for a diagnostic, and 2 for
-a usage error. Formats 1 and 2 and IR 3.1.0 are rejected; see the
+a usage error. Formats 1 and 2 and 3.x releases from 3.2.0 on are rejected; see the
[morphir package README](../morphir/README.md#morphir-ir-versions) for the
format-version rules and the draft v4 status.
diff --git a/packages/morphir-tools/src/morphir_tools/cli.py b/packages/morphir-tools/src/morphir_tools/cli.py
index d69a970e..7aa857d5 100644
--- a/packages/morphir-tools/src/morphir_tools/cli.py
+++ b/packages/morphir-tools/src/morphir_tools/cli.py
@@ -1,8 +1,8 @@
"""The ``morphir-tools`` command line.
Every IR operation goes through the `morphir` library: the shared format-version
-reader, the v3 and draft v4 codecs, and `morphir.ir.migrate`. The CLI adds file
-handling and reporting only.
+reader, the v3 (3.0.x and 3.1.x) and draft v4 codecs, and `morphir.ir.migrate`.
+The CLI adds file handling and reporting only.
Exit codes: 0 for success (including success with warnings), 1 when the library
reports a diagnostic, 2 for a usage error.
@@ -23,6 +23,7 @@
from morphir.ir.migrate import Err, Ok, v3_to_v4, v4_to_v3
from morphir.ir.migrate import drop_input_attributes as drop_inputs
from morphir.ir.v3 import codec as v3_codec
+from morphir.ir.v3 import distribution as v3_dist
from morphir.ir.v4 import codec as v4_codec
from morphir.ir.v4 import distribution as v4_dist
from morphir.ir.v4.name import Name as Name4
@@ -35,7 +36,7 @@
)
ir_app = typer.Typer(
help="Inspect and convert versioned morphir-ir.json files "
- "(format 3.0.x and draft 4.0.x).",
+ "(format 3.0.x and 3.1.x, and draft 4.0.x).",
no_args_is_help=True,
)
app.add_typer(ir_app, name="ir")
@@ -76,8 +77,12 @@ def _write_atomically(target: Path, text: str) -> None:
raise
-def _kind(dist: v4_dist.Distribution) -> str:
+def _kind(dist: v4_dist.Distribution | v3_dist.Distribution) -> str:
match dist:
+ case v3_dist.Library():
+ return "Library"
+ case v3_dist.Specs():
+ return "Specs"
case v4_dist.LibraryDistribution():
return "Library"
case v4_dist.SpecsDistribution():
@@ -95,10 +100,10 @@ def inspect(
try:
version, _ = fv.read_document(text)
if version.major == 3:
- library = v3_codec.decode_versioned_distribution(text)
- kind = "Library"
+ classic = v3_codec.decode_versioned_distribution(text)
+ kind = _kind(classic)
package = v4_codec.encode_path(
- Path4(tuple(Name4(tuple(n)) for n in library.package_name))
+ Path4(tuple(Name4(tuple(n)) for n in classic.package_name))
)
else:
dist = v4_codec.decode_versioned_distribution(text)
@@ -137,7 +142,10 @@ def convert(
),
] = False,
) -> None:
- """Convert between format 3.0.0 and draft IR v4.
+ """Convert between format 3 (3.0.x and 3.1.x) and draft IR v4.
+
+ A v3 Library is written as format 3 and a v3 Specs as format 3.1.0, the lowest
+ version that holds each.
The output is written to a temporary file and renamed only on success, so a
failed conversion leaves no output file.
@@ -150,18 +158,18 @@ def convert(
if version.major == 4:
upgraded = Ok(v4_codec.decode_versioned_distribution(text))
else:
- library_v4 = v3_to_v4(
+ migrated = v3_to_v4(
v3_codec.decode_versioned_distribution(text),
package_version=package_version,
)
- upgraded = Ok(library_v4.value, library_v4.warnings)
+ upgraded = Ok(migrated.value, migrated.warnings)
if drop_input_attributes:
upgraded = drop_inputs(upgraded.value)
document = v4_codec.encode_versioned_distribution(upgraded.value)
warnings: tuple[Diagnostic, ...] = upgraded.warnings
else:
if version.major == 3:
- library = v3_codec.decode_versioned_distribution(text)
+ classic = v3_codec.decode_versioned_distribution(text)
warnings = ()
else:
result = v4_to_v3(
@@ -171,8 +179,8 @@ def convert(
if isinstance(result, Err):
_report(result.diagnostics)
raise typer.Exit(1)
- library, warnings = result.value, result.warnings
- document = v3_codec.encode_versioned_distribution(library)
+ classic, warnings = result.value, result.warnings
+ document = v3_codec.encode_versioned_distribution(classic)
except DiagnosticError as error:
_report(error.diagnostics)
raise typer.Exit(1) from None
diff --git a/packages/morphir/README.md b/packages/morphir/README.md
index 1fc7fa9a..d1ccf028 100644
--- a/packages/morphir/README.md
+++ b/packages/morphir/README.md
@@ -35,7 +35,7 @@ from morphir.ir import Type, Value
| Package | IR | Source of truth | JSON codec |
|---------|----|-----------------|------------|
-| `morphir.ir.v3` | format 3.0.0, what morphir-elm writes today | [morphir-elm](https://github.com/finos/morphir-elm) at `bc99af69` | `morphir.ir.v3.codec`, matches morphir-elm byte for byte |
+| `morphir.ir.v3` | format 3.0.0, what morphir-elm writes today, and its 3.1.0 revision | [morphir-elm](https://github.com/finos/morphir-elm) at `bc99af69` for 3.0.0; [finos/morphir](https://github.com/finos/morphir) at `efa36060` for what 3.1.0 adds | `morphir.ir.v3.codec`, matches morphir-elm byte for byte for 3.0.0 |
| `morphir.ir.v4` | IR v4, **draft upstream** | [finos/morphir](https://github.com/finos/morphir) at `efa36060` | `morphir.ir.v4.codec`, **draft** |
`morphir.ir` and its old submodule paths (`morphir.ir.name`, `morphir.ir.value`,
@@ -64,8 +64,48 @@ assert document["formatVersion"] == 4
downgraded = v4_to_v3(upgraded.value)
assert isinstance(downgraded, Ok) and downgraded.value == library
assert read_format_version("3.0.0") == read_format_version(3)
+
+specs = v3_codec.decode_versioned_distribution(
+ Path("tests/fixtures/ir/v3/mck-distributions-0011-specs.json").read_text()
+)
+assert isinstance(specs, v3.Specs)
+assert v3_codec.encode_versioned_distribution(specs)["formatVersion"] == "3.1.0"
+assert isinstance(v3_to_v4(specs).value, v4.SpecsDistribution)
```
+### IR 3.1.0 and the v3 `Specs` distribution
+
+IR `3.1.0` is a minor revision of format 3
+([finos/morphir decision record 0018](https://github.com/finos/morphir/blob/main/kb/bundles/morphir/morphir-ir/decisions/0018-ir-3-1-adds-specs-and-v3-document-trees.md)).
+It adds `v3.Specs`, a distribution that publishes a package's specification
+without its definitions:
+
+```json
+{"formatVersion": "3.1.0", "distribution": ["Specs", [["my"], ["pkg"]], [], {"modules": []}]}
+```
+
+A v3 distribution is `v3.Library | v3.Specs`, and
+`v3_codec.decode_versioned_distribution` returns either. The rules are:
+
+- A `Specs` needs `3.1.0` or later. A `Specs` that declares `3`, `"3"` or any
+ `"3.0.x"` fails with `specs_before_3_1`. A `Library` reads at every `3.0.x` and
+ `3.1.x` release.
+- The modules of a `Specs` distribution's own package are module
+ specifications. A member other than `types`, `values` and `doc`, such as the
+ `access` of a module definition, fails with `unknown_member`. `types` and
+ `values` are required, as morphir-elm requires them in every module
+ specification; morphir-rust lets them default to empty.
+- Writers emit the lowest version that fits: `3` for a `Library`, even one read
+ as `"3.1.0"`, and `"3.1.0"` for a `Specs`. Readers that stay on 3.0.0, such as
+ morphir-elm, still read every `Library` this package writes.
+- `v3_to_v4` turns a v3 `Specs` into a v4 `SpecsDistribution`, and `v4_to_v3`
+ turns a v4 `SpecsDistribution` into a v3 `Specs`, with the diagnostics of a
+ `Library`'s dependencies.
+
+Decision 0018 also adds v3 JSON and YAML document trees. This package has no
+document tree reader or writer, for v3 or v4; bead `morphir-ukvn.8.17` tracks
+that work.
+
### Reading `formatVersion`
`morphir.ir.format_version` reads the version of every IR document before any
@@ -81,10 +121,9 @@ files written by other tools:
- the [finos/morphir format-version contract](https://github.com/finos/morphir/blob/main/docs/spec/ir/format-version.md)
accepts `3` and `"3.0.0"` but rejects `"3"`.
-The support table is `[3.0.0,3.1.0),[4.0.0,4.1.0)`. Formats 1 and 2 fail with
-`unsupported_format_version_major`; IR 3.1.0 fails with
-`unsupported_format_version_minor` until
-[finos/morphir-python#28](https://github.com/finos/morphir-python/issues/28) lands.
+The support table is `[3.0.0,3.2.0),[4.0.0,4.1.0)`, the finos reference table.
+Formats 1 and 2 fail with `unsupported_format_version_major`; a later 3.x minor
+such as `"3.2.0"` fails with `unsupported_format_version_minor`.
Failures raise `morphir.ir.diagnostics.DiagnosticError`, whose diagnostics carry
the stable codes of the finos contract.
@@ -135,12 +174,12 @@ with the warning `package_version_dropped`.
### Migrations
-`morphir.ir.migrate.v3_to_v4` always succeeds for a v3 Library.
+`morphir.ir.migrate.v3_to_v4` always succeeds for a v3 Library or Specs.
`v4_to_v3` returns `Ok(value, warnings)` or `Err(diagnostics)` and reports every
-construct format 3.0.0 cannot hold in one pass: Specs and Application
-distributions, `Hole`, `Native`, `External`, `DocumentLiteral`, native, external
-and incomplete bodies, incomplete type definitions, `Union`, annotations, a
-missing `inferred_type`, semantic attributes, and missing member documentation. Value
+construct format 3 cannot hold in one pass: Application distributions, `Hole`,
+`Native`, `External`, `DocumentLiteral`, native, external and incomplete
+bodies, incomplete type definitions, `Union`, annotations, a missing
+`inferred_type`, semantic attributes, and missing member documentation. Value
source locations fail unless you pass `drop_source_locations=True`.
v4 JSON spells a value-definition input as a bare type, so it has no place for
diff --git a/tests/tools/test_cli.py b/tests/tools/test_cli.py
index fa876037..d4294f14 100644
--- a/tests/tools/test_cli.py
+++ b/tests/tools/test_cli.py
@@ -12,6 +12,8 @@
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "ir" / "v3"
VALUE_TREE = FIXTURES / "elm-simpleValueTree.json"
REFERENCE = FIXTURES / "elm-reference-model.json"
+KIT_SPECS = FIXTURES / "mck-distributions-0011-specs.json"
+RUST_SPECS = FIXTURES / "rust-classic-specs-transport.json"
runner = CliRunner()
@@ -60,6 +62,27 @@ def test_format_3(self) -> None:
assert "package: morphir/example/app" in result.stdout
assert "draft" not in result.stdout
+ def test_v3_specs(self) -> None:
+ result = run("ir", "inspect", KIT_SPECS)
+ assert result.exit_code == 0
+ assert "format: 3.1.0" in result.stdout
+ assert "kind: Specs" in result.stdout
+ assert "package: my/pkg" in result.stdout
+
+ def test_library_at_3_1_0(self, tmp_path: Path) -> None:
+ doc = json.loads(VALUE_TREE.read_text())
+ doc["formatVersion"] = "3.1.0"
+ result = run("ir", "inspect", write(tmp_path, "x.json", doc))
+ assert result.exit_code == 0
+ assert "format: 3.1.0" in result.stdout
+ assert "kind: Library" in result.stdout
+
+ def test_specs_before_3_1(self, tmp_path: Path) -> None:
+ doc = {"formatVersion": 3, "distribution": ["Specs", [["a"]], [], {}]}
+ result = run("ir", "inspect", write(tmp_path, "x.json", doc))
+ assert result.exit_code == 1
+ assert f"{d.SPECS_BEFORE_3_1} at formatVersion" in result.stderr
+
def test_v4_is_labelled_draft(self, v4_file: Path) -> None:
result = run("ir", "inspect", v4_file)
assert result.exit_code == 0
@@ -147,6 +170,29 @@ def test_same_version_rewrites_canonically(
assert run("ir", "convert", VALUE_TREE, "--to", "v3", "-o", out).exit_code == 0
assert run("ir", "convert", v4_file, "--to", "v4", "-o", out).exit_code == 0
+ def test_specs_upgrade_and_downgrade(self, tmp_path: Path) -> None:
+ up = tmp_path / "up.json"
+ result = run("ir", "convert", RUST_SPECS, "--to", "v4", "-o", up)
+ assert result.exit_code == 0
+ written = json.loads(up.read_text())
+ assert written["formatVersion"] == 4
+ assert "Specs" in written["distribution"]
+ back = tmp_path / "back.json"
+ result = run("ir", "convert", up, "--to", "v3", "-o", back)
+ assert result.exit_code == 0
+ assert result.stderr == ""
+ assert json.loads(back.read_text()) == json.loads(RUST_SPECS.read_text())
+
+ def test_v3_rewrite_emits_the_lowest_version(self, tmp_path: Path) -> None:
+ doc = json.loads(VALUE_TREE.read_text())
+ doc["formatVersion"] = "3.1.0"
+ out = tmp_path / "out.json"
+ source = write(tmp_path, "x.json", doc)
+ assert run("ir", "convert", source, "--to", "v3", "-o", out).exit_code == 0
+ assert json.loads(out.read_text())["formatVersion"] == 3
+ assert run("ir", "convert", KIT_SPECS, "--to", "v3", "-o", out).exit_code == 0
+ assert json.loads(out.read_text()) == json.loads(KIT_SPECS.read_text())
+
def test_downgrade_failure_leaves_no_output(self, tmp_path: Path) -> None:
doc = {
"formatVersion": 4,
diff --git a/tests/unit/ir/test_format_version.py b/tests/unit/ir/test_format_version.py
index fc215d0c..e3328d01 100644
--- a/tests/unit/ir/test_format_version.py
+++ b/tests/unit/ir/test_format_version.py
@@ -9,6 +9,7 @@
V3_1,
V3_TABLE,
FormatVersion,
+ Interval,
dump_json,
parse_json,
read_document,
@@ -107,6 +108,15 @@ def test_family_table_refuses_other_family(self) -> None:
assert info.value.codes == (d.UNSUPPORTED_FORMAT_VERSION_MAJOR,)
assert "only 3.0.x and 3.1.x ([3.0.0,3.2.0))" in str(info.value)
+ def test_custom_table_description_names_its_lowest_family(self) -> None:
+ table = (
+ Interval(FormatVersion(3, 0, 0), FormatVersion(3, 0, 5)),
+ Interval(FormatVersion(4, 0, 0), FormatVersion(5, 0, 0)),
+ )
+ with pytest.raises(DiagnosticError) as info:
+ read_format_version(6, table)
+ assert "only 3.0.x and draft 4.0.x" in str(info.value)
+
def test_table_spelling(self) -> None:
assert table_to_string(SUPPORT_TABLE) == "[3.0.0,3.2.0),[4.0.0,4.1.0)"
From 74896dcbeccdefadf252d96db33556fe232cb739 Mon Sep 17 00:00:00 2001
From: Damian Reeves <957246+DamianReeves@users.noreply.github.com>
Date: Tue, 27 Oct 2026 02:08:14 -0500
Subject: [PATCH 5/7] chore(openspec): archive add-ir-3-1-0 and sync the IR
specs
---
.../2026-10-27-add-ir-3-1-0}/.openspec.yaml | 0
.../2026-10-27-add-ir-3-1-0}/design.html | 10 +--
.../2026-10-27-add-ir-3-1-0}/proposal.md | 0
.../specs/ir/format-version/spec.md | 0
.../specs/ir/migrate/spec.md | 6 +-
.../specs/ir/v3/spec.md | 0
.../specs/tools/ir-interchange/spec.md | 10 ++-
.../2026-10-27-add-ir-3-1-0}/tasks.md | 0
openspec/specs/ir/format-version/spec.md | 26 +++---
openspec/specs/ir/migrate/spec.md | 83 ++++++++++++-------
openspec/specs/ir/v3/spec.md | 62 ++++++++++++--
openspec/specs/tools/ir-interchange/spec.md | 24 ++++--
12 files changed, 156 insertions(+), 65 deletions(-)
rename openspec/changes/{add-ir-3-1-0 => archive/2026-10-27-add-ir-3-1-0}/.openspec.yaml (100%)
rename openspec/changes/{add-ir-3-1-0 => archive/2026-10-27-add-ir-3-1-0}/design.html (96%)
rename openspec/changes/{add-ir-3-1-0 => archive/2026-10-27-add-ir-3-1-0}/proposal.md (100%)
rename openspec/changes/{add-ir-3-1-0 => archive/2026-10-27-add-ir-3-1-0}/specs/ir/format-version/spec.md (100%)
rename openspec/changes/{add-ir-3-1-0 => archive/2026-10-27-add-ir-3-1-0}/specs/ir/migrate/spec.md (88%)
rename openspec/changes/{add-ir-3-1-0 => archive/2026-10-27-add-ir-3-1-0}/specs/ir/v3/spec.md (100%)
rename openspec/changes/{add-ir-3-1-0 => archive/2026-10-27-add-ir-3-1-0}/specs/tools/ir-interchange/spec.md (79%)
rename openspec/changes/{add-ir-3-1-0 => archive/2026-10-27-add-ir-3-1-0}/tasks.md (100%)
diff --git a/openspec/changes/add-ir-3-1-0/.openspec.yaml b/openspec/changes/archive/2026-10-27-add-ir-3-1-0/.openspec.yaml
similarity index 100%
rename from openspec/changes/add-ir-3-1-0/.openspec.yaml
rename to openspec/changes/archive/2026-10-27-add-ir-3-1-0/.openspec.yaml
diff --git a/openspec/changes/add-ir-3-1-0/design.html b/openspec/changes/archive/2026-10-27-add-ir-3-1-0/design.html
similarity index 96%
rename from openspec/changes/add-ir-3-1-0/design.html
rename to openspec/changes/archive/2026-10-27-add-ir-3-1-0/design.html
index c863e088..65e63349 100644
--- a/openspec/changes/add-ir-3-1-0/design.html
+++ b/openspec/changes/archive/2026-10-27-add-ir-3-1-0/design.html
@@ -13,11 +13,11 @@
Code goes in
(or json, toml, bash, diff). Give sections an id and
list them in nav.toc.
-->
-
-
-
-
-
+
+
+
+
+
diff --git a/openspec/changes/add-ir-3-1-0/proposal.md b/openspec/changes/archive/2026-10-27-add-ir-3-1-0/proposal.md
similarity index 100%
rename from openspec/changes/add-ir-3-1-0/proposal.md
rename to openspec/changes/archive/2026-10-27-add-ir-3-1-0/proposal.md
diff --git a/openspec/changes/add-ir-3-1-0/specs/ir/format-version/spec.md b/openspec/changes/archive/2026-10-27-add-ir-3-1-0/specs/ir/format-version/spec.md
similarity index 100%
rename from openspec/changes/add-ir-3-1-0/specs/ir/format-version/spec.md
rename to openspec/changes/archive/2026-10-27-add-ir-3-1-0/specs/ir/format-version/spec.md
diff --git a/openspec/changes/add-ir-3-1-0/specs/ir/migrate/spec.md b/openspec/changes/archive/2026-10-27-add-ir-3-1-0/specs/ir/migrate/spec.md
similarity index 88%
rename from openspec/changes/add-ir-3-1-0/specs/ir/migrate/spec.md
rename to openspec/changes/archive/2026-10-27-add-ir-3-1-0/specs/ir/migrate/spec.md
index bf70a9e5..f3f4bc2d 100644
--- a/openspec/changes/add-ir-3-1-0/specs/ir/migrate/spec.md
+++ b/openspec/changes/archive/2026-10-27-add-ir-3-1-0/specs/ir/migrate/spec.md
@@ -17,6 +17,10 @@ The library SHALL migrate v4 to v3. When the input uses concepts v3 cannot repre
- **WHEN** a v4 Library contains a `Hole`, `Native`, or `External` value, or a `DocumentLiteral`
- **THEN** migration fails with `unsupported_v4_downgrade` naming the construct and its location
+#### Scenario: V4-only definitions and types are reported
+- **WHEN** a v4 Library or Specs contains a native, external or incomplete value-definition body, an incomplete type definition, a `Union` type, or a specification with annotations
+- **THEN** migration fails with `unsupported_v4_downgrade` naming the construct and its location
+
#### Scenario: Missing types are reported
- **WHEN** a v4 value, pattern, or value-definition input has no `inferred_type`
- **THEN** migration fails with `missing_inferred_type` at that location
@@ -38,7 +42,7 @@ The library SHALL migrate a v3 `Specs` to a v4 `Specs` and a v4 `Specs` to a v3
#### Scenario: V3 Specs migrates forward
- **WHEN** a v3 Specs is migrated to v4
-- **THEN** the result is a v4 Specs whose package name, dependencies and module specifications match the source
+- **THEN** the result is a v4 Specs whose package name, dependencies and module specifications match the source, with no annotations
#### Scenario: Compatible v4 Specs downgrades
- **WHEN** a v4 Specs uses only concepts v3 can represent
diff --git a/openspec/changes/add-ir-3-1-0/specs/ir/v3/spec.md b/openspec/changes/archive/2026-10-27-add-ir-3-1-0/specs/ir/v3/spec.md
similarity index 100%
rename from openspec/changes/add-ir-3-1-0/specs/ir/v3/spec.md
rename to openspec/changes/archive/2026-10-27-add-ir-3-1-0/specs/ir/v3/spec.md
diff --git a/openspec/changes/add-ir-3-1-0/specs/tools/ir-interchange/spec.md b/openspec/changes/archive/2026-10-27-add-ir-3-1-0/specs/tools/ir-interchange/spec.md
similarity index 79%
rename from openspec/changes/add-ir-3-1-0/specs/tools/ir-interchange/spec.md
rename to openspec/changes/archive/2026-10-27-add-ir-3-1-0/specs/tools/ir-interchange/spec.md
index 9771e5f5..1a8072d0 100644
--- a/openspec/changes/add-ir-3-1-0/specs/tools/ir-interchange/spec.md
+++ b/openspec/changes/archive/2026-10-27-add-ir-3-1-0/specs/tools/ir-interchange/spec.md
@@ -20,7 +20,7 @@ The `ir inspect` command SHALL read a file with the shared format-version reader
- **THEN** the report states that v4 support is draft
### Requirement: Convert between v3 and v4
-The `ir convert` command SHALL convert format 3 (3.0.x and 3.1.x) to v4 and v4 to format 3 through the library migrations, for `Library` and `Specs` distributions. It SHALL accept an optional package version for upgrades and an opt-in flag to drop source locations on downgrade. Success writes the target file; failure exits non-zero, prints each diagnostic, and leaves no output file. (bead: morphir-ukvn.8.13)
+The `ir convert` command SHALL convert format 3 (3.0.x and 3.1.x) to v4 and v4 to format 3 through the library migrations, for `Library` and `Specs` distributions. It SHALL accept an optional package version and an opt-in flag to drop per-input attributes for upgrades, and an opt-in flag to drop source locations on downgrade. Success writes the target file; failure exits non-zero, prints each diagnostic, and leaves no output file. (bead: morphir-ukvn.8.13)
#### Scenario: Upgrade v3 file to v4
- **WHEN** the user converts a valid format-3 Library file to v4
@@ -38,6 +38,14 @@ The `ir convert` command SHALL convert format 3 (3.0.x and 3.1.x) to v4 and v4 t
- **WHEN** a downgrade succeeds with dropped information
- **THEN** the tool prints each warning code and still exits 0
+#### Scenario: Upgrade with input attributes needs opt-in
+- **WHEN** the user converts the morphir-elm reference model to v4 without `--drop-input-attributes`
+- **THEN** the tool exits non-zero with `no_v4_spelling`, names the flag, and does not create the output file
+
+#### Scenario: Upgrade drops input attributes on request
+- **WHEN** the user converts the same file with `--drop-input-attributes`
+- **THEN** the tool writes a `formatVersion` 4 file, prints each `input_attributes_dropped` warning, and exits 0
+
## REMOVED Requirements
### Requirement: Scope excludes formats 1, 2, and 3.1
diff --git a/openspec/changes/add-ir-3-1-0/tasks.md b/openspec/changes/archive/2026-10-27-add-ir-3-1-0/tasks.md
similarity index 100%
rename from openspec/changes/add-ir-3-1-0/tasks.md
rename to openspec/changes/archive/2026-10-27-add-ir-3-1-0/tasks.md
diff --git a/openspec/specs/ir/format-version/spec.md b/openspec/specs/ir/format-version/spec.md
index 7510ec7c..b9b20b79 100644
--- a/openspec/specs/ir/format-version/spec.md
+++ b/openspec/specs/ir/format-version/spec.md
@@ -27,17 +27,6 @@ Accepting string spellings SHALL be documented as a deliberate divergence from m
- **WHEN** a user reads the package documentation for format version handling
- **THEN** it states which accepted spellings morphir-elm and the finos/morphir contract would reject
-### Requirement: Support table is checked before decoding
-The reader SHALL declare the support table `[3.0.0,3.1.0),[4.0.0,4.1.0)` and MUST finish recognition and the support check before invoking any distribution decoder or migration. A release outside the table SHALL fail with `unsupported_format_version_major` or `unsupported_format_version_minor`. (bead: morphir-ukvn.8)
-
-#### Scenario: IR 3.1.0 is refused for now
-- **WHEN** a document declares `formatVersion` `"3.1.0"`
-- **THEN** reading fails with `unsupported_format_version_minor` before the distribution is decoded
-
-#### Scenario: Historical versions are refused
-- **WHEN** a document declares `formatVersion` `1` or `2`
-- **THEN** reading fails with `unsupported_format_version_major` and a message that formats 1 and 2 are follow-up work
-
### Requirement: Stable format version diagnostics
Format-version failures SHALL use the finos/morphir contract codes: `missing_format_version`, `duplicate_format_version`, `invalid_format_version_type`, `invalid_format_version_syntax`, `format_version_out_of_range`, `unsupported_format_version_major`, and `unsupported_format_version_minor`. (bead: morphir-ukvn.8)
@@ -55,3 +44,18 @@ Writers SHALL emit `formatVersion` first, as the integer `N` for a baseline rele
#### Scenario: Read string, write integer
- **WHEN** a document read with `formatVersion` `"3.0.0"` is written again
- **THEN** the output declares `formatVersion` as the integer `3`
+
+### Requirement: Support table with IR 3.1 is checked before decoding
+The reader SHALL declare the support table `[3.0.0,3.2.0),[4.0.0,4.1.0)` and MUST finish recognition and the support check before invoking any distribution decoder or migration. A release outside the table SHALL fail with `unsupported_format_version_major` or `unsupported_format_version_minor`. (bead: morphir-ukvn.8.13)
+
+#### Scenario: IR 3.1.0 is supported
+- **WHEN** a document declares `formatVersion` `"3.1.0"` or `"3.1.4"`
+- **THEN** the support check passes and the distribution is decoded
+
+#### Scenario: A later 3.x minor is refused
+- **WHEN** a document declares `formatVersion` `"3.2.0"`
+- **THEN** reading fails with `unsupported_format_version_minor` before the distribution is decoded, and the message names the accepted table `[3.0.0,3.2.0),[4.0.0,4.1.0)`
+
+#### Scenario: Historical versions are refused
+- **WHEN** a document declares `formatVersion` `1` or `2`
+- **THEN** reading fails with `unsupported_format_version_major` and a message that formats 1 and 2 are follow-up work
diff --git a/openspec/specs/ir/migrate/spec.md b/openspec/specs/ir/migrate/spec.md
index 2f1a0c91..c78c44da 100644
--- a/openspec/specs/ir/migrate/spec.md
+++ b/openspec/specs/ir/migrate/spec.md
@@ -1,7 +1,7 @@
# ir/migrate Specification
## Purpose
-Defines migrations between Morphir IR format 3.0.0 and IR v4, including how attributes, documentation, and package versions map, so programs move forward safely and reverse migration fails explicitly when v4 content cannot be represented in v3.
+Defines migrations between Morphir IR format 3 (3.0.0 and 3.1.0) and IR v4, including how attributes, documentation, and package versions map, so programs move forward safely and reverse migration fails explicitly when v4 content cannot be represented in v3.
## Requirements
@@ -16,37 +16,6 @@ The library SHALL migrate a valid v3 Library to a v4 Library. Unit type attribut
- **WHEN** a v3 Library is migrated with package version `1.2.0`
- **THEN** the v4 package identity carries `1.2.0`, and without that argument the version is absent
-### Requirement: Reverse migrate v4 to v3 with known failure paths
-The library SHALL migrate v4 to v3. When the input uses concepts v3 cannot represent, migration SHALL fail with structured errors and MUST NOT invent or drop IR content, except as allowed by the dropped-information requirement. (bead: morphir-ukvn.8)
-
-#### Scenario: Specs and Application do not downgrade
-- **WHEN** a v4 Specs or Application distribution is migrated to v3
-- **THEN** migration fails with `unsupported_v4_downgrade` naming the distribution kind
-
-#### Scenario: V4-only nodes are reported
-- **WHEN** a v4 Library contains a `Hole`, `Native`, or `External` value, or a `DocumentLiteral`
-- **THEN** migration fails with `unsupported_v4_downgrade` naming the construct and its location
-
-#### Scenario: V4-only definitions and types are reported
-- **WHEN** a v4 Library contains a native, external or incomplete value-definition body, an incomplete type definition, a `Union` type, or a specification with annotations
-- **THEN** migration fails with `unsupported_v4_downgrade` naming the construct and its location
-
-#### Scenario: Missing types are reported
-- **WHEN** a v4 value, pattern, or value-definition input has no `inferred_type`
-- **THEN** migration fails with `missing_inferred_type` at that location
-
-#### Scenario: Semantic attributes are reported
-- **WHEN** v4 type attributes carry a source, constraints, or extensions, or value attributes carry extensions
-- **THEN** migration fails with `unsupported_attributes_downgrade`
-
-#### Scenario: Absent member documentation is reported
-- **WHEN** a v4 type or value member has no documentation
-- **THEN** migration fails with `unsupported_documentation_downgrade`, because v3 member documentation is a required string
-
-#### Scenario: Compatible Library downgrades
-- **WHEN** a v4 Library uses only concepts v3 can represent
-- **THEN** migration succeeds with a v3 Library
-
### Requirement: Dropped information is limited and reported
Reverse migration SHALL drop the package release version, which v3 cannot hold, and report `package_version_dropped`. Value source locations SHALL fail with `unsupported_attributes_downgrade` unless the caller opts in to dropping them, which reports `source_location_dropped`. Nothing else MAY be dropped. (bead: morphir-ukvn.8)
@@ -86,3 +55,53 @@ The library SHALL let a caller drop the value attributes of value-definition inp
#### Scenario: Inputs that lose nothing report nothing
- **WHEN** every input's attributes are empty or hold only its declared type
- **THEN** the drop reports no warning
+
+### Requirement: Reverse migrate v4 Library and Specs to v3 with known failure paths
+The library SHALL migrate v4 to v3. When the input uses concepts v3 cannot represent, migration SHALL fail with structured errors and MUST NOT invent or drop IR content, except as allowed by the dropped-information requirement. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Application does not downgrade
+- **WHEN** a v4 Application distribution is migrated to v3
+- **THEN** migration fails with `unsupported_v4_downgrade` naming the distribution kind, because format 3 has no Application
+
+#### Scenario: V4-only nodes are reported
+- **WHEN** a v4 Library contains a `Hole`, `Native`, or `External` value, or a `DocumentLiteral`
+- **THEN** migration fails with `unsupported_v4_downgrade` naming the construct and its location
+
+#### Scenario: V4-only definitions and types are reported
+- **WHEN** a v4 Library or Specs contains a native, external or incomplete value-definition body, an incomplete type definition, a `Union` type, or a specification with annotations
+- **THEN** migration fails with `unsupported_v4_downgrade` naming the construct and its location
+
+#### Scenario: Missing types are reported
+- **WHEN** a v4 value, pattern, or value-definition input has no `inferred_type`
+- **THEN** migration fails with `missing_inferred_type` at that location
+
+#### Scenario: Semantic attributes are reported
+- **WHEN** v4 type attributes carry a source, constraints, or extensions, or value attributes carry extensions
+- **THEN** migration fails with `unsupported_attributes_downgrade`
+
+#### Scenario: Absent member documentation is reported
+- **WHEN** a v4 type or value member has no documentation
+- **THEN** migration fails with `unsupported_documentation_downgrade`, because v3 member documentation is a required string
+
+#### Scenario: Compatible Library downgrades
+- **WHEN** a v4 Library uses only concepts v3 can represent
+- **THEN** migration succeeds with a v3 Library
+
+### Requirement: Specs migrate between v3 and v4
+The library SHALL migrate a v3 `Specs` to a v4 `Specs` and a v4 `Specs` to a v3 `Specs`. Each specification SHALL map as the dependency specifications of a `Library` map, with the same diagnostics, warnings and caller-supplied package version. (bead: morphir-ukvn.8.13)
+
+#### Scenario: V3 Specs migrates forward
+- **WHEN** a v3 Specs is migrated to v4
+- **THEN** the result is a v4 Specs whose package name, dependencies and module specifications match the source, with no annotations
+
+#### Scenario: Compatible v4 Specs downgrades
+- **WHEN** a v4 Specs uses only concepts v3 can represent
+- **THEN** migration succeeds with a v3 Specs, and a package version is dropped with `package_version_dropped`
+
+#### Scenario: Incompatible v4 Specs is reported
+- **WHEN** a v4 Specs has a module member without documentation
+- **THEN** migration fails with `unsupported_documentation_downgrade` at that member
+
+#### Scenario: V3 Specs survives v3 to v4 to v3
+- **WHEN** a v3 Specs is migrated to v4 and back
+- **THEN** the result is semantically equal to the original
diff --git a/openspec/specs/ir/v3/spec.md b/openspec/specs/ir/v3/spec.md
index f8b20172..c0ff87c9 100644
--- a/openspec/specs/ir/v3/spec.md
+++ b/openspec/specs/ir/v3/spec.md
@@ -1,19 +1,23 @@
# ir/v3 Specification
## Purpose
-Provides a first-class Morphir IR format 3.0.0 model and JSON codecs in Python, with morphir-elm as the normative source, so tooling can load and produce the IR that morphir-elm emits today.
+Provides a first-class Morphir IR format 3 model and JSON codecs in Python (3.0.0, with morphir-elm as the normative source, and the 3.1.0 Specs distribution, with finos/morphir as the normative source), so tooling can load and produce the IR that morphir-elm and other v3 tools emit.
## Requirements
### Requirement: morphir-elm is normative for v3 content
-The v3 model and JSON shapes SHALL follow morphir-elm `Morphir.IR.*` types and `*.Codec` modules for format 3.0.0. Where finos/morphir v3 schemas or docs disagree with morphir-elm on 3.0.0 content, morphir-elm MUST win. (bead: morphir-ukvn.8)
+The v3 model and JSON shapes SHALL follow morphir-elm `Morphir.IR.*` types and `*.Codec` modules for format 3.0.0. Where finos/morphir v3 schemas or docs disagree with morphir-elm on 3.0.0 content, morphir-elm MUST win. What format 3.1.0 adds, which morphir-elm does not implement, SHALL follow the finos/morphir v3 specification and decision record 0018. (bead: morphir-ukvn.8.13)
#### Scenario: Elm output decodes
- **WHEN** a `morphir-ir.json` written by morphir-elm `morphir make` is decoded
- **THEN** decoding succeeds without a Python-specific JSON variant
+#### Scenario: The finos 3.1.0 example decodes
+- **WHEN** the v3 `Specs` document of the finos/morphir kit case distributions-0011 is decoded and encoded again
+- **THEN** decoding succeeds and the output is equal to the kit's canonical JSON
+
### Requirement: V3 model mirrors morphir-elm concepts
-The library SHALL expose a v3 model whose concepts match morphir-elm: names as word lists, paths, qualified and fully qualified names, access control, documented values, literals, types, values, patterns, modules, packages, and the `Library` distribution. A v3 `Library` SHALL carry type attributes of unit and value attributes of a v3 type, as in elm. (bead: morphir-ukvn.8)
+The library SHALL expose a v3 model whose concepts match morphir-elm: names as word lists, paths, qualified and fully qualified names, access control, documented values, literals, types, values, patterns, modules, packages, and the `Library` distribution, plus the `Specs` distribution of format 3.1.0. A v3 `Library` SHALL carry type attributes of unit and value attributes of a v3 type, as in elm. (bead: morphir-ukvn.8.13)
#### Scenario: Library distribution is representable
- **WHEN** a caller constructs a v3 Library with a package name, dependency specifications, and a package definition whose values carry types
@@ -23,8 +27,12 @@ The library SHALL expose a v3 model whose concepts match morphir-elm: names as w
- **WHEN** a caller constructs a v3 Library
- **THEN** the model has no package release version, because format 3.0.0 has none
+#### Scenario: Specs distribution is representable
+- **WHEN** a caller constructs a v3 Specs with a package name, dependency specifications, and a package specification
+- **THEN** the model keeps those parts, holds no definitions, and has no package release version
+
### Requirement: V3 JSON matches morphir-elm format 3.0.0
-The library SHALL encode and decode versioned distribution JSON with the shapes of morphir-elm `Morphir.IR.FormatVersion.Codec` and the codecs it uses. Node codecs SHALL take attribute codecs as parameters, as elm does. Version recognition SHALL use the shared format-version reader. (bead: morphir-ukvn.8)
+The library SHALL encode and decode versioned distribution JSON with the shapes of morphir-elm `Morphir.IR.FormatVersion.Codec` and the codecs it uses. Node codecs SHALL take attribute codecs as parameters, as elm does. Version recognition SHALL use the shared format-version reader. (bead: morphir-ukvn.8.13)
#### Scenario: Round trip versioned morphir-ir.json
- **WHEN** a valid morphir-elm `morphir-ir.json` with `formatVersion` 3 is decoded and encoded again
@@ -36,11 +44,11 @@ The library SHALL encode and decode versioned distribution JSON with the shapes
#### Scenario: Wrong family at the v3 entry point
- **WHEN** a document with `formatVersion` 4 is passed to the v3 decoder
-- **THEN** decoding fails with `unsupported_format_version_major` naming the found version and that only 3.0.x is accepted
+- **THEN** decoding fails with `unsupported_format_version_major` naming the found version and that only 3.0.x and 3.1.x are accepted
#### Scenario: Unknown distribution kind
-- **WHEN** a format-3 document has a distribution tag other than `Library`
-- **THEN** decoding fails with a structured error naming the tag
+- **WHEN** a format-3 document has a distribution tag other than `Library` or `Specs`
+- **THEN** decoding fails with `unknown_node` naming the tag and both known kinds
### Requirement: V3 is independently usable
Callers SHALL be able to construct, inspect, encode, and decode v3 IR without converting to v4, and v3 classes SHALL be distinct from v4 classes. (bead: morphir-ukvn.8)
@@ -52,3 +60,43 @@ Callers SHALL be able to construct, inspect, encode, and decode v3 IR without co
#### Scenario: Versions do not mix
- **WHEN** a caller passes a v4 node where the v3 model expects a v3 node
- **THEN** static type checking reports an error
+
+### Requirement: V3 Specs distribution JSON
+The library SHALL encode and decode a v3 `Specs` distribution as `["Specs", package, dependencies, specification]`, where `specification` is a package specification whose modules are spelled as a dependency's modules are. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Specs round trips
+- **WHEN** a document with `formatVersion` `"3.1.0"` and a `Specs` distribution is decoded and encoded again
+- **THEN** the output declares `formatVersion` `"3.1.0"` and its distribution is semantically equal to the input
+
+#### Scenario: A Specs distribution with dependencies
+- **WHEN** a `Specs` distribution lists dependency package specifications
+- **THEN** they decode as the dependencies of a `Library` do
+
+### Requirement: A Specs distribution needs 3.1.0 or later
+The v3 reader SHALL refuse a `Specs` distribution whose document declares a release below `3.1.0`, such as `3`, `"3"` or `"3.0.1"`, with `specs_before_3_1` at the `formatVersion` member. A `Library` SHALL stay readable at every supported 3.x release. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Specs declared as 3 is refused
+- **WHEN** a document declares `formatVersion` `3`, `"3"`, `"3.0.0"` or `"3.0.1"` and holds a `Specs` distribution
+- **THEN** decoding fails with `specs_before_3_1`
+
+#### Scenario: Library declared as 3.1.0 is read
+- **WHEN** a document declares `formatVersion` `"3.1.0"` and holds a `Library` distribution
+- **THEN** it decodes as a v3 Library
+
+### Requirement: A Specs distribution holds specifications only
+The v3 reader SHALL refuse a member other than `types`, `values` and `doc` in a module of a `Specs` distribution's own package specification with `unknown_member`, so a module definition is not read as a specification. Dependency modules keep the morphir-elm reading. (bead: morphir-ukvn.8.13)
+
+#### Scenario: A module definition in a Specs is refused
+- **WHEN** a `Specs` distribution's own module is spelled `{"access": "Public", "value": {...}}`
+- **THEN** decoding fails with `unknown_member` at that module
+
+### Requirement: Writers emit the lowest version that fits
+The v3 writer SHALL emit `formatVersion` `3` for a `Library` and `"3.1.0"` for a `Specs`, whatever version the input declared, so every reader of 3.0.0 still reads every `Library` this library writes. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Library read as 3.1.0 is written as 3
+- **WHEN** a `Library` read from a document that declares `"3.1.0"` is encoded
+- **THEN** the output declares the integer `formatVersion` `3`
+
+#### Scenario: Specs is written as 3.1.0
+- **WHEN** a v3 `Specs` is encoded
+- **THEN** the output declares `formatVersion` `"3.1.0"`
diff --git a/openspec/specs/tools/ir-interchange/spec.md b/openspec/specs/tools/ir-interchange/spec.md
index 77091c3f..c686c1e7 100644
--- a/openspec/specs/tools/ir-interchange/spec.md
+++ b/openspec/specs/tools/ir-interchange/spec.md
@@ -1,17 +1,21 @@
# tools/ir-interchange Specification
## Purpose
-Gives morphir-tools users commands to inspect versioned `morphir-ir.json` files and convert them between format 3.0.0 and draft IR v4, using the same library readers, codecs, and migrations that Python callers use.
+Gives morphir-tools users commands to inspect versioned `morphir-ir.json` files and convert them between format 3 (3.0.x and 3.1.x) and draft IR v4, using the same library readers, codecs, and migrations that Python callers use.
## Requirements
### Requirement: Inspect versioned morphir-ir.json
-The `ir inspect` command SHALL read a file with the shared format-version reader, decode it with the matching v3 or v4 codec, and report the normalized format version, the distribution kind, and the package name. (bead: morphir-ukvn.8)
+The `ir inspect` command SHALL read a file with the shared format-version reader, decode it with the matching v3 or v4 codec, and report the normalized format version, the distribution kind, and the package name. (bead: morphir-ukvn.8.13)
#### Scenario: Report format version 3
- **WHEN** the user inspects a morphir-elm `morphir-ir.json` with `formatVersion` 3
- **THEN** the tool reports format 3.0.0, kind Library, and the package name, and exits 0
+#### Scenario: Report a v3 Specs
+- **WHEN** the user inspects a file with `formatVersion` `"3.1.0"` and a `Specs` distribution
+- **THEN** the tool reports format 3.1.0, kind Specs, and the package name, and exits 0
+
#### Scenario: Unversioned IR is rejected clearly
- **WHEN** the input JSON has no `formatVersion` member
- **THEN** the tool exits non-zero, reports `missing_format_version`, and says the IR should be regenerated
@@ -28,14 +32,18 @@ The tools MUST NOT parse IR JSON with ad-hoc CLI code; reading, decoding, encodi
- **THEN** the tool reports the same diagnostic codes the library returns
### Requirement: Convert between v3 and v4
-The `ir convert` command SHALL convert format 3.0.0 to v4 and v4 to format 3.0.0 through the library migrations. It SHALL accept an optional package version and an opt-in flag to drop per-input attributes for upgrades, and an opt-in flag to drop source locations on downgrade. Success writes the target file; failure exits non-zero, prints each diagnostic, and leaves no output file. (bead: morphir-ukvn.8)
+The `ir convert` command SHALL convert format 3 (3.0.x and 3.1.x) to v4 and v4 to format 3 through the library migrations, for `Library` and `Specs` distributions. It SHALL accept an optional package version and an opt-in flag to drop per-input attributes for upgrades, and an opt-in flag to drop source locations on downgrade. Success writes the target file; failure exits non-zero, prints each diagnostic, and leaves no output file. (bead: morphir-ukvn.8.13)
#### Scenario: Upgrade v3 file to v4
- **WHEN** the user converts a valid format-3 Library file to v4
- **THEN** the tool writes a `formatVersion` 4 file and exits 0
+#### Scenario: Specs converts both ways
+- **WHEN** the user converts a v3 Specs file to v4, or a compatible v4 Specs file to v3
+- **THEN** the tool writes a v4 Specs with `formatVersion` 4, or a v3 Specs with `formatVersion` `"3.1.0"`, and exits 0
+
#### Scenario: Downgrade failure is visible
-- **WHEN** the user converts a v4 file that cannot be represented in format 3.0.0
+- **WHEN** the user converts a v4 file that cannot be represented in format 3
- **THEN** the tool exits non-zero, prints each diagnostic code and location, and does not create the output file
#### Scenario: Downgrade warnings are printed
@@ -50,9 +58,9 @@ The `ir convert` command SHALL convert format 3.0.0 to v4 and v4 to format 3.0.0
- **WHEN** the user converts the same file with `--drop-input-attributes`
- **THEN** the tool writes a `formatVersion` 4 file, prints each `input_attributes_dropped` warning, and exits 0
-### Requirement: Scope excludes formats 1, 2, and 3.1
-For this change, the tools MUST reject formats 1, 2, and 3.1.x rather than load or convert them. This differs from morphir-elm, which still reads format 1. (bead: morphir-ukvn.8)
+### Requirement: Scope excludes formats 1 and 2 and later 3.x minors
+The tools MUST reject formats 1 and 2 and any 3.x release from 3.2.0 on, rather than load or convert them. This differs from morphir-elm, which still reads format 1. (bead: morphir-ukvn.8.13)
#### Scenario: Unsupported format version is named
-- **WHEN** the user supplies a file whose `formatVersion` is 1, 2, or `"3.1.0"`
-- **THEN** the tool exits non-zero with the format-version diagnostic code and says only 3.0.x and draft 4.0.x are supported
+- **WHEN** the user supplies a file whose `formatVersion` is 1, 2, or `"3.2.0"`
+- **THEN** the tool exits non-zero with the format-version diagnostic code and says only 3.0.x, 3.1.x and draft 4.0.x are supported
From 50fd23ffdc8142505a1152be30593eb539b1d61e Mon Sep 17 00:00:00 2001
From: Damian Reeves <957246+DamianReeves@users.noreply.github.com>
Date: Tue, 27 Oct 2026 02:11:20 -0500
Subject: [PATCH 6/7] chore: remove the behave pretty.output artifact and
ignore it
---
.gitignore | 3 ++
pretty.output | 127 --------------------------------------------------
2 files changed, 3 insertions(+), 127 deletions(-)
delete mode 100644 pretty.output
diff --git a/.gitignore b/.gitignore
index 675d80ea..52171e00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -70,3 +70,6 @@ yarn-error.log*
# Beads runtime lock at repo root (bd creates this; do not commit)
.beads.gate.lock
+
+# behave writes the pretty formatter to this file when its output is piped
+pretty.output
diff --git a/pretty.output b/pretty.output
deleted file mode 100644
index c20f07c1..00000000
--- a/pretty.output
+++ /dev/null
@@ -1,127 +0,0 @@
-Feature: IR interchange # features/ir_interchange.feature:1
- As a Morphir tooling user
- I want to inspect and convert versioned morphir-ir.json files
- So that I can move models between IR format 3.0.0 and draft IR v4
- Scenario: Inspect a morphir-elm format 3 file # features/ir_interchange.feature:6
- Given the golden fixture "elm-simpleValueTree.json" # features/steps/ir_interchange_steps.py:31
- When I run "ir inspect" on it # features/steps/ir_interchange_steps.py:46
- Then the command exits with code 0 # features/steps/ir_interchange_steps.py:81
- And the output contains "format: 3.0.0" # features/steps/ir_interchange_steps.py:87
- And the output contains "kind: Library" # features/steps/ir_interchange_steps.py:87
- And the output contains "package: morphir/example/app" # features/steps/ir_interchange_steps.py:87
-
- Scenario: Upgrade a format 3 file to draft v4 and back # features/ir_interchange.feature:14
- Given the golden fixture "elm-simpleValueTree.json" # features/steps/ir_interchange_steps.py:31
- When I convert it to "v4" # features/steps/ir_interchange_steps.py:52
- Then the command exits with code 0 # features/steps/ir_interchange_steps.py:81
- And the converted file declares formatVersion 4 # features/steps/ir_interchange_steps.py:99
- When I inspect the converted file # features/steps/ir_interchange_steps.py:68
- Then the output contains "draft" # features/steps/ir_interchange_steps.py:87
- When I convert the converted file to "v3" # features/steps/ir_interchange_steps.py:74
- Then the command exits with code 0 # features/steps/ir_interchange_steps.py:81
- And the converted file has the same JSON as the fixture # features/steps/ir_interchange_steps.py:105
-
- Scenario: Unversioned IR is rejected # features/ir_interchange.feature:25
- Given a file with the JSON '{"distribution": []}' # features/steps/ir_interchange_steps.py:38
- When I run "ir inspect" on it # features/steps/ir_interchange_steps.py:46
- Then the command exits with code 1 # features/steps/ir_interchange_steps.py:81
- And the errors contain "missing_format_version" # features/steps/ir_interchange_steps.py:93
-
- Scenario: Specs cannot be downgraded # features/ir_interchange.feature:31
- Given a file with the JSON '{"formatVersion": 4, "distribution": {"Specs": {"packageName": "a"}}}' # features/steps/ir_interchange_steps.py:38
- When I convert it to "v3" # features/steps/ir_interchange_steps.py:52
- Then the command exits with code 1 # features/steps/ir_interchange_steps.py:81
- And the errors contain "unsupported_v4_downgrade" # features/steps/ir_interchange_steps.py:93
- And no converted file exists # features/steps/ir_interchange_steps.py:113
-
-Feature: Morphir SDK # features/morphir_sdk.feature:1
- As a developer of Morphir models
- I want a Python runtime for the Morphir SDK
- So that compiled models behave as they do in Elm
- Scenario Outline: Every core SDK module can be imported -- @1.1 # features/morphir_sdk.feature:12
- When I import the SDK module "basics" # features/steps/sdk_steps.py:23
- Then the SDK module should be available # features/steps/sdk_steps.py:29
-
- Scenario Outline: Every core SDK module can be imported -- @1.2 # features/morphir_sdk.feature:13
- When I import the SDK module "char" # features/steps/sdk_steps.py:23
- Then the SDK module should be available # features/steps/sdk_steps.py:29
-
- Scenario Outline: Every core SDK module can be imported -- @1.3 # features/morphir_sdk.feature:14
- When I import the SDK module "string" # features/steps/sdk_steps.py:23
- Then the SDK module should be available # features/steps/sdk_steps.py:29
-
- Scenario Outline: Every core SDK module can be imported -- @1.4 # features/morphir_sdk.feature:15
- When I import the SDK module "list" # features/steps/sdk_steps.py:23
- Then the SDK module should be available # features/steps/sdk_steps.py:29
-
- Scenario Outline: Every core SDK module can be imported -- @1.5 # features/morphir_sdk.feature:16
- When I import the SDK module "dict" # features/steps/sdk_steps.py:23
- Then the SDK module should be available # features/steps/sdk_steps.py:29
-
- Scenario Outline: Every core SDK module can be imported -- @1.6 # features/morphir_sdk.feature:17
- When I import the SDK module "set" # features/steps/sdk_steps.py:23
- Then the SDK module should be available # features/steps/sdk_steps.py:29
-
- Scenario Outline: Every core SDK module can be imported -- @1.7 # features/morphir_sdk.feature:18
- When I import the SDK module "maybe" # features/steps/sdk_steps.py:23
- Then the SDK module should be available # features/steps/sdk_steps.py:29
-
- Scenario Outline: Every core SDK module can be imported -- @1.8 # features/morphir_sdk.feature:19
- When I import the SDK module "result" # features/steps/sdk_steps.py:23
- Then the SDK module should be available # features/steps/sdk_steps.py:29
-
- Scenario Outline: Every core SDK module can be imported -- @1.9 # features/morphir_sdk.feature:20
- When I import the SDK module "tuple" # features/steps/sdk_steps.py:23
- Then the SDK module should be available # features/steps/sdk_steps.py:29
-
- Scenario Outline: Every core SDK module can be imported -- @1.10 # features/morphir_sdk.feature:21
- When I import the SDK module "decimal" # features/steps/sdk_steps.py:23
- Then the SDK module should be available # features/steps/sdk_steps.py:29
-
- Scenario Outline: Every core SDK module can be imported -- @1.11 # features/morphir_sdk.feature:22
- When I import the SDK module "int" # features/steps/sdk_steps.py:23
- Then the SDK module should be available # features/steps/sdk_steps.py:29
-
- Scenario Outline: Every core SDK module can be imported -- @1.12 # features/morphir_sdk.feature:23
- When I import the SDK module "number" # features/steps/sdk_steps.py:23
- Then the SDK module should be available # features/steps/sdk_steps.py:29
-
- Scenario: A dictionary lists its entries in key order # features/morphir_sdk.feature:25
- Given an SDK dictionary built from the pairs "b=2, a=1, c=3" # features/steps/sdk_steps.py:36
- When I list the keys of the dictionary # features/steps/sdk_steps.py:48
- Then the keys should be "a, b, c" # features/steps/sdk_steps.py:54
-
- Scenario: Two dictionaries built in a different order are equal # features/morphir_sdk.feature:30
- Given an SDK dictionary built from the pairs "b=2, a=1" # features/steps/sdk_steps.py:36
- And another SDK dictionary built from the pairs "a=1, b=2" # features/steps/sdk_steps.py:42
- Then the two dictionaries should be equal # features/steps/sdk_steps.py:60
-
- Scenario: Rounding follows Elm and not Python # features/morphir_sdk.feature:35
- When I round the float 2.5 with the SDK # features/steps/sdk_steps.py:67
- Then the rounded value should be 3 # features/steps/sdk_steps.py:73
-
- Scenario: Decimal arithmetic is exact # features/morphir_sdk.feature:39
- When I add the decimals "0.1" and "0.2" with the SDK # features/steps/sdk_steps.py:79
- Then the decimal result should print as "0.3" # features/steps/sdk_steps.py:87
-
-Feature: Morphir Python Setup # features/morphir_setup.feature:1
- As a developer
- I want to verify the project is set up correctly
- So that I can start developing Morphir features
- Scenario: Morphir core library can be imported # features/morphir_setup.feature:6
- Given the Python environment is set up # features/steps/setup_steps.py:13
- When I import the morphir module # features/steps/setup_steps.py:22
- Then the module should be available # features/steps/setup_steps.py:46
- And it should have a version attribute # features/steps/setup_steps.py:52
-
- Scenario: Morphir tools can be imported # features/morphir_setup.feature:12
- Given the Python environment is set up # features/steps/setup_steps.py:13
- When I import the morphir_tools module # features/steps/setup_steps.py:30
- Then the module should be available # features/steps/setup_steps.py:46
- And it should have a version attribute # features/steps/setup_steps.py:52
-
- Scenario: Morphir IR module can be imported # features/morphir_setup.feature:18
- Given the Python environment is set up # features/steps/setup_steps.py:13
- When I import the morphir.ir module # features/steps/setup_steps.py:38
- Then the module should be available # features/steps/setup_steps.py:46
-
From 726ae69b94108e11e9fba982a14d4cde845a9d57 Mon Sep 17 00:00:00 2001
From: Damian Reeves <957246+DamianReeves@users.noreply.github.com>
Date: Tue, 27 Oct 2026 02:19:35 -0500
Subject: [PATCH 7/7] test(ir): pin how v3 Specs migrations meet v4 annotations
and input drops
A v3 Specs upgrades with no annotations and encodes as v4, also with the
input-attribute drop requested; a v4 Specs whose value specification carries
an annotation is refused with unsupported_v4_downgrade.
---
tests/unit/ir/test_migrate.py | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/tests/unit/ir/test_migrate.py b/tests/unit/ir/test_migrate.py
index 749618c6..669a8c6d 100644
--- a/tests/unit/ir/test_migrate.py
+++ b/tests/unit/ir/test_migrate.py
@@ -14,6 +14,7 @@
from morphir.ir.v3 import package as pkg3
from morphir.ir.v3 import type as t3
from morphir.ir.v3 import value as val3
+from morphir.ir.v4 import annotation as ann4
from morphir.ir.v4 import codec as v4_codec
from morphir.ir.v4 import document as doc4
from morphir.ir.v4 import incompleteness as inc4
@@ -500,3 +501,29 @@ def test_v4_specs_round_trips_through_v3(self) -> None:
assert isinstance(down, Ok)
up = v3_to_v4(down.value)
assert up.value == specs()
+
+ def test_v3_specs_upgrade_has_no_annotations(self) -> None:
+ dist = load(FIXTURES / "mck-distributions-0011-specs.json")
+ assert isinstance(dist, v3.Specs)
+ up = v3_to_v4(dist, drop_input_attributes=True)
+ assert up.warnings == ()
+ assert isinstance(up.value, SpecsDistribution)
+ for mod in up.value.specification.modules.values():
+ for tpe in mod.types.values():
+ assert tpe.value.annotations == []
+ assert v4_codec.encode_versioned_distribution(up.value)["formatVersion"] == 4
+
+ def test_annotated_v4_specs_is_refused(self) -> None:
+ marker = ann4.CompactAnnotation(FQName(PKG, IRPath((n("a"),)), n("b")))
+ spec = module_spec()
+ inc = spec.values[n("inc")]
+ annotated = dataclasses.replace(
+ spec,
+ values={
+ n("inc"): Documented(
+ inc.doc, dataclasses.replace(inc.value, annotations=[marker])
+ )
+ },
+ )
+ result = v4_to_v3(specs(spec=annotated))
+ assert errors(result) == [(d.UNSUPPORTED_V4_DOWNGRADE, "Annotations")]