diff --git a/.github/workflows/specfact.yml b/.github/workflows/specfact.yml
index b96e4bc1..7d35878e 100644
--- a/.github/workflows/specfact.yml
+++ b/.github/workflows/specfact.yml
@@ -61,6 +61,10 @@ jobs:
hatch env create || true
pip install -e .
+ - name: Enforce Core-Module Isolation
+ run: |
+ hatch run pytest tests/unit/test_core_module_isolation.py -v
+
- name: Set validation parameters
id: validation
run: |
diff --git a/.markdownlintignore b/.markdownlintignore
new file mode 100644
index 00000000..d0e6085e
--- /dev/null
+++ b/.markdownlintignore
@@ -0,0 +1,3 @@
+docs/_site/
+docs/vendor/
+docs/project-plans/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 053f26f3..1632bb7d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,24 @@ All notable changes to this project will be documented in this file.
---
+## [0.30.0] - 2026-02-08
+
+### Added (0.30.0)
+
+- ModuleIOContract protocol for formal module interfaces.
+- Static analysis enforcement of core-module isolation.
+- ProjectBundle schema versioning (`schema_version` field).
+- ValidationReport model for structured validation results.
+- Protocol compliance tracking in module metadata.
+
+### Changed (0.30.0)
+
+- Updated modules `backlog`, `sync`, `plan`, `generate`, and `enforce` to expose ModuleIOContract operations.
+- Added module contracts documentation and ProjectBundle schema reference docs.
+- Reference: `(fixes #206)`.
+
+---
+
## [0.29.0] - 2026-02-06
### Added (0.29.0)
diff --git a/README.md b/README.md
index 185b9d0b..b11ad60d 100644
--- a/README.md
+++ b/README.md
@@ -153,6 +153,12 @@ SpecFact now has a lifecycle-managed module system:
This lifecycle model is the baseline for future granular module updates and enhancements. Module installation from third-party or open-source community providers is planned, but not implemented yet.
+Contract-first module architecture highlights:
+
+- `ModuleIOContract` formalizes module IO operations (`import`, `export`, `sync`, `validate`) on `ProjectBundle`.
+- Core-module isolation is enforced by static analysis (`core` never imports `specfact_cli.modules.*` directly).
+- Registration tracks protocol operation coverage and schema compatibility metadata.
+
---
## Developer Note: Command Layout
diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html
index 3a3af71b..7d431dd1 100644
--- a/docs/_layouts/default.html
+++ b/docs/_layouts/default.html
@@ -171,6 +171,8 @@
Architecture
Operational Modes
Directory Structure
+ ProjectBundle Schema
+ Module Contracts
Integrations Overview
diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md
index 20d9c1fd..b0077605 100644
--- a/docs/reference/architecture.md
+++ b/docs/reference/architecture.md
@@ -259,6 +259,28 @@ transitions:
### 2. Contract Layer
+## Contract-First Module Development
+
+SpecFact module development follows a contract-first pattern:
+
+- `ModuleIOContract` formalizes module IO on top of `ProjectBundle`.
+- `ValidationReport` standardizes module validation output.
+- Registration validates supported protocol operations and declared schema compatibility.
+
+### Core-Module Isolation Principle
+
+Core runtime paths (`cli.py`, `registry/`, `models/`, `utils/`, `contracts/`) must not import from
+`specfact_cli.modules.*` directly.
+
+- Core invokes module capabilities through `CommandRegistry`.
+- Modules are discovered and loaded lazily.
+- Static isolation tests enforce this boundary in CI.
+
+See also:
+
+- [Module Contracts](module-contracts.md)
+- [ProjectBundle Schema](projectbundle-schema.md)
+
#### Runtime Contracts (icontract)
```python
diff --git a/docs/reference/module-contracts.md b/docs/reference/module-contracts.md
new file mode 100644
index 00000000..837368f3
--- /dev/null
+++ b/docs/reference/module-contracts.md
@@ -0,0 +1,55 @@
+---
+layout: default
+title: Module Contracts
+permalink: /reference/module-contracts/
+description: ModuleIOContract protocol, validation output model, and isolation rules for module developers.
+---
+
+# Module Contracts
+
+SpecFact modules integrate through a protocol-first interface and inversion-of-control loading.
+
+## ModuleIOContract
+
+`ModuleIOContract` defines four operations:
+
+- `import_to_bundle(source: Path, config: dict) -> ProjectBundle`
+- `export_from_bundle(bundle: ProjectBundle, target: Path, config: dict) -> None`
+- `sync_with_bundle(bundle: ProjectBundle, external_source: str, config: dict) -> ProjectBundle`
+- `validate_bundle(bundle: ProjectBundle, rules: dict) -> ValidationReport`
+
+Implementations should use runtime contracts (`@icontract`) and runtime type validation (`@beartype`).
+
+## ValidationReport
+
+`ValidationReport` provides structured validation output:
+
+- `status`: `passed | failed | warnings`
+- `violations`: list of maps with `severity`, `message`, `location`
+- `summary`: counts (`total_checks`, `passed`, `failed`, `warnings`)
+
+## Inversion of Control
+
+Core code must not import module code directly.
+
+- Allowed: core -> `CommandRegistry`
+- Forbidden: core -> `specfact_cli.modules.*`
+
+Module discovery and loading are done through registry-driven lazy loading.
+
+## Example Implementation
+
+```python
+@beartype
+@require(lambda source: source.exists())
+@ensure(lambda result: isinstance(result, ProjectBundle))
+def import_to_bundle(source: Path, config: dict[str, Any]) -> ProjectBundle:
+ return ProjectBundle.load_from_directory(source)
+```
+
+## Guidance for 3rd-Party Modules
+
+- Declare module metadata in `module-package.yaml`.
+- Implement as many protocol operations as your module supports.
+- Declare `schema_version` when you depend on a specific bundle IO schema.
+- Keep module logic isolated from core; rely on registry entrypoints.
diff --git a/docs/reference/projectbundle-schema.md b/docs/reference/projectbundle-schema.md
new file mode 100644
index 00000000..fe28e44c
--- /dev/null
+++ b/docs/reference/projectbundle-schema.md
@@ -0,0 +1,46 @@
+---
+layout: default
+title: ProjectBundle Schema
+permalink: /reference/projectbundle-schema/
+description: ProjectBundle fields, schema_version semantics, and compatibility guidance.
+---
+
+# ProjectBundle Schema
+
+`ProjectBundle` is the canonical IO contract used by core and module integrations.
+
+## Key Fields
+
+- `manifest`: bundle metadata and indexes (`BundleManifest`)
+- `bundle_name`: logical bundle identifier
+- `schema_version`: module-IO schema version string (default: `"1"`)
+- `idea`, `business`, `product`, `features`, `clarifications`: project content
+
+## Schema Versioning Strategy
+
+`schema_version` is a compatibility signal for module IO behavior.
+
+- Major compatibility checks in module registration compare module-declared schema version with current ProjectBundle schema.
+- Missing module schema version is treated as compatible with the current schema.
+- Incompatible module schema versions are skipped at registration time.
+
+## Example
+
+```yaml
+bundle_name: legacy-api
+schema_version: "1"
+manifest:
+ versions:
+ schema: "1.0"
+ project: "0.1.0"
+product:
+ themes: []
+ releases: []
+features: {}
+```
+
+## Backward Compatibility
+
+- Existing bundles without explicit module metadata remain usable.
+- Registration keeps legacy modules enabled when protocol methods are absent.
+- New protocol and schema checks are additive and designed for gradual migration.
diff --git a/openspec/changes/arch-04-core-contracts-interfaces/.openspec.yaml b/openspec/changes/arch-04-core-contracts-interfaces/.openspec.yaml
new file mode 100644
index 00000000..565fad56
--- /dev/null
+++ b/openspec/changes/arch-04-core-contracts-interfaces/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-02-08
diff --git a/openspec/changes/arch-04-core-contracts-interfaces/CHANGE_VALIDATION.md b/openspec/changes/arch-04-core-contracts-interfaces/CHANGE_VALIDATION.md
new file mode 100644
index 00000000..d90a2c60
--- /dev/null
+++ b/openspec/changes/arch-04-core-contracts-interfaces/CHANGE_VALIDATION.md
@@ -0,0 +1,305 @@
+# Change Validation Report: arch-04-core-contracts-interfaces
+
+**Validation Date**: 2026-02-08
+**Change Proposal**: [proposal.md](./proposal.md)
+**Validation Method**: Dry-run simulation and interface analysis
+
+## Executive Summary
+
+- **Breaking Changes**: 0 detected / 0 resolved
+- **Dependent Files**: 5 modules affected (non-breaking updates)
+- **Impact Level**: Low (additive changes only)
+- **Validation Result**: ✅ Pass
+- **User Decision**: N/A (no breaking changes, proceed to implementation)
+
+## Breaking Changes Detected
+
+**None** - This is a purely additive change. All modifications are backward compatible:
+
+1. **ProjectBundle.schema_version** - New field with default value `"1"`
+2. **ModulePackageMetadata extensions** - New optional fields with defaults
+3. **ModuleIOContract protocol** - New Protocol (opt-in, no forced inheritance)
+4. **Static analysis test** - New test, doesn't modify existing code
+5. **Module updates** - Adding protocol implementation (backward compatible)
+
+## Interface Changes (Non-Breaking)
+
+### New Interfaces Added
+
+#### ModuleIOContract Protocol
+- **File**: `src/specfact_cli/contracts/module_interface.py`
+- **Type**: New Protocol (structural subtyping)
+- **Methods**:
+ - `import_to_bundle(source: Path, config: dict) -> ProjectBundle`
+ - `export_from_bundle(bundle: ProjectBundle, target: Path, config: dict) -> None`
+ - `sync_with_bundle(bundle: ProjectBundle, external_source: str, config: dict) -> ProjectBundle`
+ - `validate_bundle(bundle: ProjectBundle, rules: dict) -> ValidationReport`
+- **Impact**: Opt-in protocol, modules can adopt incrementally
+- **Breaking**: ❌ No
+
+#### ValidationReport Model
+- **File**: `src/specfact_cli/models/validation.py`
+- **Type**: New Pydantic model
+- **Fields**: `status`, `violations`, `summary`
+- **Impact**: New model for validate_bundle return type
+- **Breaking**: ❌ No
+
+### Modified Interfaces
+
+#### ProjectBundle
+- **Old Signature**: No schema_version field
+- **New Signature**: Adds `schema_version: str = "1"` field
+- **Impact**: Backward compatible (default value provided)
+- **Breaking**: ❌ No
+- **Dependent Files**: All code using ProjectBundle continues to work
+
+#### ModulePackageMetadata
+- **Old Signature**: No schema_version or protocol_operations fields
+- **New Signature**: Adds optional fields with defaults
+ - `schema_version: str | None = None`
+ - `protocol_operations: list[str] = Field(default_factory=list)`
+- **Impact**: Backward compatible (optional fields with defaults)
+- **Breaking**: ❌ No
+- **Dependent Files**: Module discovery continues to work
+
+## Dependencies Affected
+
+### Modules Updated (Non-Breaking)
+
+All 5 modules are updated to **implement** ModuleIOContract, not modified:
+
+1. **backlog** (`src/specfact_cli/modules/backlog/src/commands.py`)
+ - **Change**: Add ModuleIOContract implementation
+ - **Impact**: Backward compatible (adding methods, not changing existing)
+ - **Breaking**: ❌ No
+
+2. **sync** (`src/specfact_cli/modules/sync/src/commands.py`)
+ - **Change**: Add ModuleIOContract implementation
+ - **Impact**: Backward compatible
+ - **Breaking**: ❌ No
+
+3. **plan** (`src/specfact_cli/modules/plan/src/commands.py`)
+ - **Change**: Add ModuleIOContract implementation
+ - **Impact**: Backward compatible
+ - **Breaking**: ❌ No
+
+4. **generate** (`src/specfact_cli/modules/generate/src/commands.py`)
+ - **Change**: Add ModuleIOContract implementation
+ - **Impact**: Backward compatible
+ - **Breaking**: ❌ No
+
+5. **enforce** (`src/specfact_cli/modules/enforce/src/commands.py`)
+ - **Change**: Add ModuleIOContract implementation
+ - **Impact**: Backward compatible
+ - **Breaking**: ❌ No
+
+### Registry Updated (Non-Breaking)
+
+**module_packages.py** (`src/specfact_cli/registry/module_packages.py`)
+- **Change**: Add protocol compliance detection via hasattr() checks
+- **Impact**: New validation layer, existing modules work without protocol
+- **Breaking**: ❌ No
+- **Behavior**: Logs INFO/WARNING based on protocol compliance, but doesn't fail registration
+
+## Impact Assessment
+
+### Code Impact
+
+- **New Files**: 7 files
+ - `src/specfact_cli/contracts/module_interface.py`
+ - `src/specfact_cli/models/validation.py`
+ - `tests/unit/test_core_module_isolation.py`
+ - `tests/unit/contracts/test_module_io_contract.py`
+ - `tests/unit/models/test_project_bundle_schema.py`
+ - `tests/unit/models/test_module_package_metadata.py`
+ - `tests/unit/registry/test_module_protocol_validation.py`
+
+- **Modified Files**: 7 files
+ - `src/specfact_cli/models/project.py` (add schema_version)
+ - `src/specfact_cli/models/module_package.py` (add schema_version, protocol_operations)
+ - `src/specfact_cli/registry/module_packages.py` (add protocol validation)
+ - 5 module command files (add ModuleIOContract implementation)
+
+- **Deleted Files**: 0
+
+### Test Impact
+
+- **New Tests**: 83 new test tasks
+- **Modified Tests**: 0
+- **Test Strategy**: TDD-first (tests written before implementation in each section)
+- **Coverage Impact**: Expected increase in coverage (new contracts, new models)
+
+### Documentation Impact
+
+- **New Docs**: 2 new reference docs
+ - `docs/reference/projectbundle-schema.md`
+ - `docs/reference/module-contracts.md`
+- **Modified Docs**: 2 updated docs
+ - `docs/reference/architecture.md` (contract-first section)
+ - `docs/_layouts/default.html` (navigation links)
+- **Impact**: High (critical for 3rd-party module developers)
+
+### Release Impact
+
+- **Version**: Minor version bump (new feature, backward compatible)
+- **Semver**: X.Y.0 (Y increments)
+- **Breaking**: ❌ No breaking changes
+- **Migration Required**: ❌ No migration needed
+
+## Format Validation
+
+### proposal.md Format
+
+✅ **Pass**
+- ✅ Title: `# Change: Core Contracts and Module Interface Formalization`
+- ✅ Section: `## Why` (motivation clear)
+- ✅ Section: `## What Changes` (NEW/MODIFY markers present)
+- ✅ Section: `## Capabilities` (2 new, 2 modified capabilities listed)
+- ✅ Section: `## Impact` (code, docs, integration points covered)
+- ✅ Source Tracking: Present (TBD for issue number)
+
+### tasks.md Format
+
+✅ **Pass**
+- ✅ TDD/SDD Order: Documented at top with enforcement note
+- ✅ Task Format: `- [ ] X.Y` checkboxes (83 tasks total)
+- ✅ Git Workflow: Task 1 = branch creation, Task 20 = PR creation
+- ✅ Quality Gates: Task 16 (format, type-check, contract-test, test coverage)
+- ✅ Documentation: Task 17 (docs research, Jekyll front-matter, navigation)
+- ✅ Version/Changelog: Task 18 (version bump, CHANGELOG.md entry)
+- ✅ GitHub Issue: Task 19 (issue creation, project linking)
+- ✅ Test-Before-Code: Verified in each section (e.g., Task 3 tests, Task 4 implementation)
+- ✅ Chunk Size: Tasks broken into 2-hour max chunks
+
+### specs Format
+
+✅ **Pass**
+- ✅ 4 spec files created:
+ - `specs/module-io-contract/spec.md` (new)
+ - `specs/core-module-isolation/spec.md` (new)
+ - `specs/module-packages/spec.md` (delta)
+ - `specs/module-lifecycle-management/spec.md` (delta)
+- ✅ Format: `#### Scenario:` with WHEN/THEN (4 hashtags, correct)
+- ✅ Each requirement has 1+ scenarios
+- ✅ Delta specs use `## ADDED Requirements` header
+
+### design.md Format
+
+✅ **Pass**
+- ✅ Sections: Context, Goals/Non-Goals, Decisions, Risks/Trade-offs
+- ✅ Sequence diagram included (module registration flow)
+- ✅ Contract enforcement strategy documented
+- ✅ Migration plan included (4 phases over 1 week)
+- ✅ Open questions answered
+
+### Config.yaml Compliance
+
+✅ **Pass**
+- ✅ TDD order enforced: Specs → Tests (expect failure) → Code
+- ✅ Contract requirements: All public APIs use @icontract + @beartype
+- ✅ Documentation: Research and review task included (Task 17)
+- ✅ Git workflow: Branch first (Task 1), PR last (Task 20)
+- ✅ Quality gates: format, type-check, contract-test, coverage (Task 16)
+- ✅ Version sync: Task 18 syncs across 4 files
+- ✅ CHANGELOG: Task 18 adds entry with semver-appropriate section
+
+## OpenSpec Validation
+
+✅ **Pass**
+- **Status**: All artifacts complete (4/4)
+- **Command**: `openspec validate arch-04-core-contracts-interfaces --strict`
+- **Result**: Change 'arch-04-core-contracts-interfaces' is valid
+- **Issues Found**: 0
+- **Issues Fixed**: 0
+
+## Backward Compatibility Analysis
+
+### Existing Code Compatibility
+
+✅ **All existing code remains functional**
+
+1. **ProjectBundle usage**: All existing instantiations work with new schema_version field (default value provided)
+2. **ModulePackageMetadata usage**: All existing metadata YAML files work (new fields optional)
+3. **Module registration**: All existing modules register successfully (protocol is opt-in, warns but doesn't fail)
+4. **Module commands**: All existing module commands work unchanged (protocol implementation is additive)
+
+### Migration Path
+
+**No migration required** - Change is fully backward compatible:
+
+1. **Immediate**: All existing modules continue working
+2. **Gradual**: Modules can adopt ModuleIOContract incrementally
+3. **Future**: New modules SHOULD implement ModuleIOContract for marketplace compatibility
+4. **Enforcement**: Static analysis prevents core→module imports (new violations only)
+
+## Risk Assessment
+
+### Low Risk Factors
+
+1. **Additive changes only** - No removal or modification of existing interfaces
+2. **Default values** - All new fields have sensible defaults
+3. **Opt-in protocol** - Modules not forced to implement immediately
+4. **Backward compatible** - Existing modules work without changes
+5. **Well-tested** - 83 tasks with TDD-first approach
+6. **Documentation** - Comprehensive docs for 3rd-party developers
+
+### Mitigation Strategies
+
+1. **Static analysis** - Catches core→module violations at CI time
+2. **Protocol validation** - Logs warnings for legacy modules (doesn't block)
+3. **Schema versioning** - Enables future-proof schema evolution
+4. **Incremental adoption** - Modules updated one at a time (backlog first as template)
+
+## Recommendations
+
+### Before Implementation
+
+✅ **All checks passed** - Change is ready for implementation
+
+1. ✅ No breaking changes detected
+2. ✅ All format validations passed
+3. ✅ TDD/SDD order enforced
+4. ✅ Git workflow correct
+5. ✅ Documentation comprehensive
+6. ✅ OpenSpec validation passed
+
+### During Implementation
+
+1. **Follow TDD order strictly** - Run tests expecting failure before writing code
+2. **Use backlog module as template** - Update it first, then replicate for others
+3. **Verify static analysis passes** - Run isolation test after each module update
+4. **Document protocol examples** - Include working examples in module-contracts.md
+5. **Test protocol compliance** - Verify hasattr() detection works correctly
+
+### After Implementation
+
+1. **Monitor module adoption** - Track which modules implement ModuleIOContract
+2. **Update documentation** - Ensure docs reflect actual behavior
+3. **Create follow-up issues** - Track migration of all modules to protocol (if not complete)
+4. **Validate marketplace readiness** - Verify protocol is sufficient for arch-05 (Bridge Registry)
+
+## Validation Artifacts
+
+- **OpenSpec validation**: ✅ Passed `openspec validate arch-04-core-contracts-interfaces --strict`
+- **Format validation**: ✅ All artifacts (proposal, tasks, specs, design) comply with config.yaml
+- **Breaking change analysis**: ✅ No breaking changes detected (0 interface removals/modifications)
+- **Dependency analysis**: ✅ All affected files remain backward compatible
+- **Task validation**: ✅ 83 tasks with correct TDD order and git workflow
+
+## Final Verdict
+
+**✅ APPROVED FOR IMPLEMENTATION**
+
+This change introduces formal contracts for module interfaces in a fully backward-compatible way. All validations pass, no breaking changes detected, and the implementation path is clear with comprehensive TDD-first tasks.
+
+**Next Steps**:
+1. Create GitHub issue (Task 19)
+2. Begin implementation following tasks.md
+3. Use `/opsx:apply` to start task execution
+4. Monitor protocol adoption across 5 modules
+
+---
+
+**Validated By**: Claude Sonnet 4.5 (OpenSpec Workflows)
+**Validation Tool**: OpenSpec Validate Change Workflow
+**Report Generated**: 2026-02-08
diff --git a/openspec/changes/arch-04-core-contracts-interfaces/design.md b/openspec/changes/arch-04-core-contracts-interfaces/design.md
new file mode 100644
index 00000000..e9e3d814
--- /dev/null
+++ b/openspec/changes/arch-04-core-contracts-interfaces/design.md
@@ -0,0 +1,278 @@
+# Design: Core Contracts and Module Interface Formalization
+
+## Context
+
+The modular architecture introduced in arch-01/02/03 (v0.27-0.29) provides:
+- CommandRegistry with lazy loading (arch-01)
+- Module package separation with boundary guards (arch-02)
+- Module lifecycle management with dependency validation (arch-03)
+
+However, the core IO contract (ProjectBundle) lacks formal protocol definitions, and while boundary guards prevent cross-module imports, there's no enforcement preventing core code from importing modules. This creates coupling that would block marketplace adoption where 3rd-party modules must be truly pluggable without core modifications.
+
+**Current state:**
+- ProjectBundle defined in `src/specfact_cli/models/project.py` as Pydantic BaseModel
+- Modules consume/produce ProjectBundle informally
+- No explicit `ModuleIOContract` protocol
+- Boundary guards only prevent module→module coupling (via `test_module_boundary_imports.py`)
+- Core can still import from modules (no static analysis prevention)
+
+**Foundation for marketplace:** This change establishes the contract-first foundation that phases arch-05 (Bridge Registry), arch-06 (Security), arch-07 (Schema Extensions), and marketplace-01/02 depend on.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Define formal `ModuleIOContract` protocol that all modules must implement
+- Enforce ProjectBundle as the ONLY IO contract between core and modules
+- Add static analysis to prevent core→module imports (inversion-of-control enforcement)
+- Update existing 5 modules (backlog, sync, plan, generate, enforce) to implement protocol
+- Document ProjectBundle schema and module contract requirements for 3rd-party developers
+
+**Non-Goals:**
+- Schema extension mechanism (deferred to arch-07)
+- Bridge registry for schema conversions (deferred to arch-05)
+- Cryptographic module signing (deferred to arch-06)
+- Marketplace infrastructure (deferred to marketplace-01/02)
+- Breaking changes to existing module interfaces (backward compatible formalization)
+
+## Decisions
+
+### Decision 1: Protocol over Abstract Base Class
+
+**Choice:** Use `typing.Protocol` for `ModuleIOContract` instead of ABC
+
+**Rationale:**
+- Structural subtyping: Modules don't need explicit inheritance
+- Duck typing: Existing modules work without modification (opt-in formalization)
+- Static type checking: basedpyright verifies compliance without runtime overhead
+- Flexibility: Modules can implement subset of operations (e.g., sync-only modules)
+
+**Alternatives considered:**
+- ABC with abstract methods: Requires explicit inheritance, breaks existing modules
+- No protocol: Informal contracts are error-prone and block marketplace verification
+
+### Decision 2: Four Core Operations
+
+**Choice:** Define 4 required operations: `import_to_bundle`, `export_from_bundle`, `sync_with_bundle`, `validate_bundle`
+
+**Rationale:**
+- **import_to_bundle**: External format → ProjectBundle (e.g., ADO work items → features)
+- **export_from_bundle**: ProjectBundle → External format (e.g., features → ADO work items)
+- **sync_with_bundle**: Bidirectional sync with conflict resolution
+- **validate_bundle**: Module-specific validation rules (e.g., backlog module validates feature IDs exist in ADO)
+
+**Alternatives considered:**
+- Single `transform()` method: Too generic, loses operation semantics
+- Separate read/write protocols: Overcomplicates simple unidirectional modules
+
+### Decision 3: AST-Based Static Analysis for Core Isolation
+
+**Choice:** Parse core directory ASTs and fail if any `import specfact_cli.modules.*` found
+
+**Rationale:**
+- Compile-time enforcement: Catches violations before PR merge
+- Zero runtime overhead: AST parsing in tests only
+- Clear error messages: Pinpoint file and line number of violation
+- CI-enforceable: Blocks PRs that add core→module coupling
+
+**Alternatives considered:**
+- Runtime inspection: Overhead, detects after deployment
+- Import hooks: Complex, fragile, runtime cost
+- Manual code review: Error-prone, doesn't scale
+
+**Implementation:**
+```python
+# tests/unit/test_core_module_isolation.py
+def test_core_has_no_module_imports():
+ core_dirs = [
+ Path("src/specfact_cli/cli.py"),
+ Path("src/specfact_cli/registry/"),
+ Path("src/specfact_cli/models/"),
+ Path("src/specfact_cli/utils/"),
+ Path("src/specfact_cli/contracts/"),
+ ]
+ for file_path in collect_python_files(core_dirs):
+ tree = ast.parse(file_path.read_text())
+ for node in ast.walk(tree):
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
+ module = get_module_name(node)
+ if module.startswith("specfact_cli.modules."):
+ violations.append(f"{file_path}:{node.lineno} imports {module}")
+ assert not violations, "Core imports module code"
+```
+
+### Decision 4: Gradual Module Migration
+
+**Choice:** Update 5 existing modules incrementally, mark protocol as optional initially
+
+**Rationale:**
+- Non-breaking: Existing modules work without immediate changes
+- Incremental: Update backlog first (simplest), then sync, plan, generate, enforce
+- Validation: Module registration can check protocol compliance and warn if missing
+
+**Alternatives considered:**
+- Big-bang migration: Risky, blocks PRs across modules
+- Never enforce: Defeats purpose, marketplace modules wouldn't be verifiable
+
+### Decision 5: ProjectBundle Schema Versioning
+
+**Choice:** Add `schema_version` field to ProjectBundle, document in reference docs
+
+**Rationale:**
+- Forward compatibility: Future schema changes don't break old modules
+- Marketplace safety: 3rd-party modules declare compatible schema versions
+- Migration path: Old modules continue working, new modules use versioned features
+
+**Schema:**
+```python
+class ProjectBundle(BaseModel):
+ schema_version: str = "1.0" # NEW
+ manifest: BundleManifest
+ bundle_name: str
+ idea: str
+ # ... existing fields
+```
+
+**Alternatives considered:**
+- No versioning: Schema changes break modules silently
+- Separate version file: Requires extra file management, error-prone
+
+## Risks / Trade-offs
+
+### Risk 1: Module Migration Effort
+
+**Risk:** Updating 5 modules to implement protocol is time-consuming
+
+**Mitigation:**
+- Start with simplest module (backlog) as template
+- Protocol is opt-in initially; registration warns but doesn't fail
+- Tasks include module-by-module migration with test validation
+
+### Risk 2: False Positives in Static Analysis
+
+**Risk:** AST parsing might flag valid imports (e.g., type hints, if TYPE_CHECKING)
+
+**Mitigation:**
+- Exclude `if TYPE_CHECKING:` blocks from analysis
+- Allow specific exceptions via config file if needed
+- Test against current codebase before enforcement
+
+### Risk 3: ProjectBundle Schema Evolution
+
+**Risk:** Future schema changes could break existing modules
+
+**Mitigation:**
+- Schema versioning from day 1
+- Extension fields (arch-07) allow backward-compatible additions
+- Deprecation policy for removals (2 minor versions notice)
+
+### Risk 4: Performance Overhead from Protocol Checks
+
+**Risk:** Runtime protocol validation adds overhead
+
+**Mitigation:**
+- Protocol is static type checking only (zero runtime cost)
+- Opt-in runtime checks via `isinstance(module, ModuleIOContract)` only in registration
+- CrossHair symbolic execution finds contract violations at CI time
+
+## Contract Enforcement Strategy
+
+Per project's contract-first philosophy:
+
+1. **Static Analysis (Compile Time)**
+ - AST parsing prevents core→module imports
+ - basedpyright verifies ModuleIOContract compliance
+
+2. **Registration Time**
+ - Check if module implements protocol (warn if not)
+ - Validate ProjectBundle schema version compatibility
+
+3. **Runtime (via @icontract)**
+ - `@require` preconditions on protocol methods (e.g., valid Path, non-empty config)
+ - `@ensure` postconditions (e.g., returned ProjectBundle has required fields)
+ - `@beartype` type validation
+
+4. **CI/CD**
+ - `hatch run contract-test` runs CrossHair symbolic execution
+ - `hatch run type-check` enforces protocol adherence
+ - `pytest tests/unit/test_core_module_isolation.py` blocks core→module imports
+
+## Migration Plan
+
+**Phase 1: Foundation (Week 1, Days 1-2)**
+- Create `src/specfact_cli/contracts/module_interface.py` with protocol
+- Add `tests/unit/test_core_module_isolation.py` static analysis test
+- Add CI enforcement in `.github/workflows/tests.yml`
+
+**Phase 2: Core Updates (Week 1, Days 3-4)**
+- Add `schema_version` to ProjectBundle
+- Update module registration to check protocol compliance (warn only)
+- Document in `docs/reference/projectbundle-schema.md`
+
+**Phase 3: Module Migration (Week 1, Days 4-5)**
+- Update backlog module (template for others)
+- Update sync, plan, generate, enforce modules
+- Validate with contract-first tests
+
+**Phase 4: Documentation (Week 1, Day 5)**
+- Create `docs/reference/module-contracts.md` for 3rd-party developers
+- Update architecture docs with contract-first patterns
+- Update `docs/_layouts/default.html` navigation
+
+**Rollback Strategy:**
+- Protocol is opt-in initially; disabling warnings reverts to pre-change behavior
+- Static analysis test can be skipped via `pytest -k "not test_core_module_isolation"` if needed
+- No breaking changes to existing module interfaces
+
+## Open Questions
+
+**Q1:** Should protocol methods raise specific exceptions (e.g., `ModuleImportError`, `ModuleExportError`)?
+
+**Answer:** Yes, define custom exceptions in `contracts/module_interface.py` for clear error semantics. Follow-up task to add exception hierarchy.
+
+**Q2:** How do modules declare which operations they support (e.g., import-only)?
+
+**Answer:** Optional protocol methods via `hasattr()` checks. Module registration inspects and logs supported operations. Full solution in arch-05 (bridge registry).
+
+**Q3:** Should ProjectBundle schema version be semver (e.g., "1.0.0") or simple (e.g., "1")?
+
+**Answer:** Simple integer version (e.g., "1") initially. Semver for schema extensions in arch-07. Keeps initial implementation minimal.
+
+## Sequence Diagram: Module Registration with Protocol Validation
+
+```
+┌─────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────┐
+│ CLI Init│ │ Registry │ │ Module Pkg │ │ Protocol │
+└────┬────┘ └──────┬───────┘ └─────┬──────┘ └────┬─────┘
+ │ │ │ │
+ │ discover_packages() │ │ │
+ ├────────────────────>│ │ │
+ │ │ load manifest │ │
+ │ ├───────────────────────>│ │
+ │ │ │ │
+ │ │ check protocol impl │ │
+ │ ├────────────────────────┼────────────────────>│
+ │ │ │ │
+ │ │<──────────────────────────── hasattr() checks│
+ │ │ │ │
+ │ │ [if protocol missing] │ │
+ │ │ log warning │ │
+ │ │ │ │
+ │ │ [if protocol present] │ │
+ │ │ validate schema_version│ │
+ │ ├───────────────────────>│ │
+ │ │ │ │
+ │ │ register module │ │
+ │<────────────────────┤ │ │
+ │ │ │ │
+```
+
+## Plugin Registry Extensibility
+
+This change maintains compatibility with the existing Plugin Registry pattern:
+
+- **Before:** Modules registered via manifest, no contract validation
+- **After:** Modules registered via manifest, protocol compliance checked at registration
+- **Future (arch-05):** Bridge registry extends protocol with schema converters
+- **Future (marketplace-01):** Marketplace modules validated for protocol compliance before approval
+
+No changes required to existing `src/specfact_cli/registry/module_packages.py` registry pattern; this adds validation layer on top.
diff --git a/openspec/changes/arch-04-core-contracts-interfaces/proposal.md b/openspec/changes/arch-04-core-contracts-interfaces/proposal.md
new file mode 100644
index 00000000..b6fdd809
--- /dev/null
+++ b/openspec/changes/arch-04-core-contracts-interfaces/proposal.md
@@ -0,0 +1,52 @@
+# Change: Core Contracts and Module Interface Formalization
+
+## Why
+
+
+The modular architecture (arch-01/02/03) provides strong encapsulation for parallel module development, but the core IO contract is not formally defined or enforced. Modules can still directly import from each other and core lacks explicit protocol definitions for module behavior. To enable a true marketplace for 3rd-party and community modules, we must formalize ProjectBundle as the ONLY IO contract and enforce zero core→module dependencies through static analysis. This establishes the contract foundation that all subsequent marketplace phases build upon.
+
+## What Changes
+
+
+- **NEW**: Create `src/specfact_cli/contracts/module_interface.py` with `ModuleIOContract` protocol defining the four core operations all modules must implement: `import_to_bundle()`, `export_from_bundle()`, `sync_with_bundle()`, and `validate_bundle()`
+- **NEW**: Add static analysis enforcement via `tests/unit/test_core_module_isolation.py` that parses AST and fails if core CLI code imports from `specfact_cli.modules.*`
+- **MODIFY**: Update existing modules (backlog, sync, plan, generate, enforce) to implement `ModuleIOContract` interface
+- **NEW**: Document ProjectBundle schema versioning in `docs/reference/projectbundle-schema.md`
+- **NEW**: Document module contract requirements in `docs/reference/module-contracts.md`
+- **NEW**: Add CI enforcement of core isolation via static analysis test
+
+## Capabilities
+### New Capabilities
+
+- `module-io-contract`: Protocol definition for module interfaces with ProjectBundle as the sole IO contract (import, export, sync, validate operations)
+- `core-module-isolation`: Static analysis enforcement preventing core CLI from importing module code, maintaining zero-dependency inversion-of-control architecture
+
+### Modified Capabilities
+
+- `module-packages`: Extend module package metadata and discovery to validate ModuleIOContract implementation
+- `module-lifecycle-management`: Extend registration-time validation to check ModuleIOContract conformance
+
+## Impact
+
+- **Affected specs**: New specs for `module-io-contract` and `core-module-isolation`; delta specs for `module-packages` and `module-lifecycle-management`
+- **Affected code**:
+ - `src/specfact_cli/contracts/` (new directory and module_interface.py)
+ - `src/specfact_cli/modules/*/src/commands.py` (backlog, sync, plan, generate, enforce)
+ - `src/specfact_cli/registry/module_packages.py` (contract validation)
+ - `tests/unit/test_core_module_isolation.py` (new)
+ - `.github/workflows/tests.yml` (add isolation test)
+- **Affected documentation**: New reference docs for ProjectBundle schema and module contracts; update architecture docs with contract-first module development patterns
+- **Integration points**: Module discovery, registration-time validation, future marketplace module verification
+- **Backward compatibility**: Non-breaking; adds formalized contracts to existing patterns. Existing modules work as-is but will be updated to explicitly implement the protocol.
+- **Release version**: Minor version bump (new feature/refactor, backward compatible)
+
+
+---
+
+## Source Tracking
+
+
+- **GitHub Issue**: #206
+- **Issue URL**:
+- **Last Synced Status**: in-progress
+- **Sanitized**: false
diff --git a/openspec/changes/arch-04-core-contracts-interfaces/specs/core-module-isolation/spec.md b/openspec/changes/arch-04-core-contracts-interfaces/specs/core-module-isolation/spec.md
new file mode 100644
index 00000000..bf799e01
--- /dev/null
+++ b/openspec/changes/arch-04-core-contracts-interfaces/specs/core-module-isolation/spec.md
@@ -0,0 +1,116 @@
+# Spec: Core Module Isolation
+
+## ADDED Requirements
+
+### Requirement: Static analysis test enforces zero core-to-module imports
+
+The system SHALL provide a pytest test `tests/unit/test_core_module_isolation.py` that parses AST of core CLI code and fails if any import from `specfact_cli.modules.*` is found.
+
+#### Scenario: Test scans core directories for module imports
+- **WHEN** test_core_has_no_module_imports runs
+- **THEN** it SHALL scan all Python files in: cli.py, registry/, models/, utils/, contracts/
+- **AND** SHALL parse each file's AST looking for Import and ImportFrom nodes
+
+#### Scenario: Test fails on direct module import
+- **WHEN** core code contains `import specfact_cli.modules.backlog`
+- **THEN** test SHALL fail with message: ": imports specfact_cli.modules.backlog"
+- **AND** SHALL list exact file path and line number
+
+#### Scenario: Test fails on from-import of module code
+- **WHEN** core code contains `from specfact_cli.modules.sync.src import commands`
+- **THEN** test SHALL fail with message: ": imports from specfact_cli.modules.sync.src"
+- **AND** SHALL prevent PR merge via CI
+
+#### Scenario: Test allows non-module imports
+- **WHEN** core code contains `import specfact_cli.models`
+- **THEN** test SHALL pass
+- **AND** SHALL NOT flag imports from non-module core directories
+
+### Requirement: Test excludes TYPE_CHECKING blocks
+
+The system SHALL exclude imports within `if TYPE_CHECKING:` blocks from static analysis violations.
+
+#### Scenario: Type hint import in TYPE_CHECKING block is allowed
+- **WHEN** core code has `if TYPE_CHECKING: from specfact_cli.modules.backlog import BacklogAdapter`
+- **THEN** test SHALL pass
+- **AND** SHALL NOT flag as violation since it's only for type checking
+
+#### Scenario: Runtime import disguised as TYPE_CHECKING is caught
+- **WHEN** code uses module import outside TYPE_CHECKING but has TYPE_CHECKING block elsewhere
+- **THEN** test SHALL still fail on the runtime import
+- **AND** SHALL distinguish between conditional type imports and runtime imports
+
+### Requirement: CI enforces isolation test
+
+The system SHALL run `test_core_module_isolation.py` in `.github/workflows/tests.yml` and block PRs that violate core isolation.
+
+#### Scenario: CI runs isolation test on every PR
+- **WHEN** PR is opened with core code changes
+- **THEN** GitHub Actions SHALL run `pytest tests/unit/test_core_module_isolation.py`
+- **AND** SHALL block merge if test fails
+
+#### Scenario: CI provides actionable error message on violation
+- **WHEN** isolation test fails in CI
+- **THEN** GitHub Actions log SHALL show file path, line number, and import statement
+- **AND** SHALL guide developer to use registry pattern instead of direct import
+
+### Requirement: Test provides clear violation messages
+
+The system SHALL format violation messages with file path, line number, and imported module name for easy debugging.
+
+#### Scenario: Violation message includes context
+- **WHEN** violation is detected at src/specfact_cli/cli.py line 42
+- **THEN** message SHALL be: "src/specfact_cli/cli.py:42 imports specfact_cli.modules.backlog.src.commands"
+- **AND** SHALL aggregate all violations before failing (not fail on first)
+
+#### Scenario: Multiple violations are reported together
+- **WHEN** multiple core files import from modules
+- **THEN** test SHALL list all violations in a single failure message
+- **AND** SHALL show total count: "Found 3 core-to-module import violations"
+
+### Requirement: Test is fast and maintainable
+
+The system SHALL ensure the static analysis test completes in under 2 seconds and requires no external dependencies beyond Python standard library.
+
+#### Scenario: Test parses AST efficiently
+- **WHEN** test runs on full codebase
+- **THEN** it SHALL complete within 2 seconds
+- **AND** SHALL use ast.parse() from standard library (no external parsers)
+
+#### Scenario: Test core directories are configurable
+- **WHEN** new core directories are added (e.g., contracts/)
+- **THEN** test SHALL have a CORE_DIRS constant at top of file
+- **AND** SHALL be easily updated by adding new Path to the list
+
+### Requirement: Inversion-of-control enforcement via registry pattern
+
+The system SHALL enforce that core CLI accesses modules only via CommandRegistry lazy loading, never via direct imports.
+
+#### Scenario: Core uses registry for module access
+- **WHEN** core CLI needs to invoke a module command
+- **THEN** it SHALL call `CommandRegistry.get_typer(command_name)`
+- **AND** SHALL NOT import module code directly
+
+#### Scenario: Module code is loaded lazily on demand
+- **WHEN** CommandRegistry.get_typer() is called
+- **THEN** module SHALL be loaded via importlib.util.module_from_spec
+- **AND** SHALL NOT be imported at CLI startup
+
+#### Scenario: Core-to-registry is allowed import
+- **WHEN** core CLI imports from `specfact_cli.registry`
+- **THEN** static analysis test SHALL pass
+- **AND** SHALL distinguish between registry access (allowed) and module access (forbidden)
+
+### Requirement: Documentation of isolation principle
+
+The system SHALL document the core-module isolation principle in `docs/reference/module-contracts.md` for 3rd-party module developers.
+
+#### Scenario: Docs explain inversion-of-control architecture
+- **WHEN** developer reads module-contracts.md
+- **THEN** docs SHALL explain that core never imports modules
+- **AND** SHALL illustrate registry pattern with code examples
+
+#### Scenario: Docs guide module developers on protocol implementation
+- **WHEN** developer wants to create a marketplace module
+- **THEN** docs SHALL show how to implement ModuleIOContract
+- **AND** SHALL clarify that modules are discovered and loaded, not imported by core
diff --git a/openspec/changes/arch-04-core-contracts-interfaces/specs/module-io-contract/spec.md b/openspec/changes/arch-04-core-contracts-interfaces/specs/module-io-contract/spec.md
new file mode 100644
index 00000000..789529b3
--- /dev/null
+++ b/openspec/changes/arch-04-core-contracts-interfaces/specs/module-io-contract/spec.md
@@ -0,0 +1,111 @@
+# Spec: Module IO Contract
+
+## ADDED Requirements
+
+### Requirement: ModuleIOContract protocol defines four core operations
+
+The system SHALL provide a `ModuleIOContract` Protocol in `src/specfact_cli/contracts/module_interface.py` that defines four required operations all modules must implement for interacting with ProjectBundle.
+
+#### Scenario: Protocol defines import_to_bundle operation
+- **WHEN** a module implements ModuleIOContract
+- **THEN** it MUST provide `import_to_bundle(source: Path, config: dict) -> ProjectBundle` method that converts external format to ProjectBundle
+
+#### Scenario: Protocol defines export_from_bundle operation
+- **WHEN** a module implements ModuleIOContract
+- **THEN** it MUST provide `export_from_bundle(bundle: ProjectBundle, target: Path, config: dict) -> None` method that converts ProjectBundle to external format
+
+#### Scenario: Protocol defines sync_with_bundle operation
+- **WHEN** a module implements ModuleIOContract
+- **THEN** it MUST provide `sync_with_bundle(bundle: ProjectBundle, external_source: str, config: dict) -> ProjectBundle` method for bidirectional synchronization
+
+#### Scenario: Protocol defines validate_bundle operation
+- **WHEN** a module implements ModuleIOContract
+- **THEN** it MUST provide `validate_bundle(bundle: ProjectBundle, rules: dict) -> ValidationReport` method for module-specific validation
+
+### Requirement: Protocol uses structural subtyping
+
+The system SHALL use `typing.Protocol` for ModuleIOContract to enable structural subtyping without requiring explicit inheritance.
+
+#### Scenario: Module without explicit inheritance satisfies protocol
+- **WHEN** a module class implements all four protocol methods with correct signatures
+- **THEN** basedpyright type checker SHALL recognize it as implementing ModuleIOContract
+- **AND** no explicit inheritance or registration is required
+
+#### Scenario: Module with partial implementation is type-checked
+- **WHEN** a module class implements only some protocol methods
+- **THEN** basedpyright SHALL report protocol violations during type checking
+- **AND** module registration SHALL detect missing methods via hasattr() checks
+
+### Requirement: Protocol methods use ProjectBundle as sole IO contract
+
+The system SHALL enforce that all ModuleIOContract methods accept or return ProjectBundle as the data exchange format.
+
+#### Scenario: Import operation returns ProjectBundle
+- **WHEN** import_to_bundle is called with valid external source
+- **THEN** it MUST return a ProjectBundle instance
+- **AND** the returned bundle SHALL have all required fields populated
+
+#### Scenario: Export operation accepts ProjectBundle
+- **WHEN** export_from_bundle is called with ProjectBundle
+- **THEN** it MUST accept ProjectBundle as input
+- **AND** SHALL NOT require any other data structure for core export logic
+
+#### Scenario: Sync operation uses ProjectBundle bidirectionally
+- **WHEN** sync_with_bundle is called
+- **THEN** it MUST accept ProjectBundle as input
+- **AND** MUST return ProjectBundle as output
+- **AND** SHALL NOT use intermediate formats bypassing ProjectBundle
+
+### Requirement: Protocol methods have icontract decorators
+
+The system SHALL require all ModuleIOContract implementations to use `@icontract` and `@beartype` decorators for runtime validation.
+
+#### Scenario: Import method has preconditions
+- **WHEN** import_to_bundle is implemented
+- **THEN** it MUST have `@require` decorator validating source path exists
+- **AND** MUST have `@beartype` decorator for type checking
+
+#### Scenario: Export method has postconditions
+- **WHEN** export_from_bundle is implemented
+- **THEN** it MUST have `@ensure` decorator validating target file was created
+- **AND** MUST have `@beartype` decorator for type checking
+
+#### Scenario: Validate method returns ValidationReport
+- **WHEN** validate_bundle is implemented
+- **THEN** it MUST return ValidationReport instance
+- **AND** MUST have `@ensure` decorator validating report structure
+
+### Requirement: Protocol supports optional operation subsets
+
+The system SHALL allow modules to implement subsets of ModuleIOContract operations based on their functionality.
+
+#### Scenario: Import-only module omits export methods
+- **WHEN** a module only supports importing from external systems
+- **THEN** it MAY implement only import_to_bundle and validate_bundle
+- **AND** module registration SHALL detect and log supported operations
+
+#### Scenario: Sync-only module implements full bidirectional operations
+- **WHEN** a module supports bidirectional sync
+- **THEN** it MUST implement all four operations
+- **AND** sync_with_bundle SHALL use import_to_bundle and export_from_bundle internally
+
+#### Scenario: Validation-only module omits IO operations
+- **WHEN** a module only validates bundles without external IO
+- **THEN** it MAY implement only validate_bundle
+- **AND** SHALL NOT be required to implement import/export/sync operations
+
+### Requirement: ValidationReport model for validate_bundle results
+
+The system SHALL provide a `ValidationReport` Pydantic model for structured validation results.
+
+#### Scenario: ValidationReport has status field
+- **WHEN** validate_bundle returns ValidationReport
+- **THEN** report MUST have `status` field with values: "passed", "failed", "warnings"
+
+#### Scenario: ValidationReport has violations list
+- **WHEN** validation finds issues
+- **THEN** report MUST have `violations` list of dicts with keys: severity, message, location
+
+#### Scenario: ValidationReport has summary field
+- **WHEN** validation completes
+- **THEN** report MUST have `summary` field with counts: total_checks, passed, failed, warnings
diff --git a/openspec/changes/arch-04-core-contracts-interfaces/specs/module-lifecycle-management/spec.md b/openspec/changes/arch-04-core-contracts-interfaces/specs/module-lifecycle-management/spec.md
new file mode 100644
index 00000000..90e4ead0
--- /dev/null
+++ b/openspec/changes/arch-04-core-contracts-interfaces/specs/module-lifecycle-management/spec.md
@@ -0,0 +1,63 @@
+# Spec: Module Lifecycle Management (Delta)
+
+## ADDED Requirements
+
+### Requirement: Registration validates ModuleIOContract implementation
+
+The system SHALL extend registration-time validation to check if module implements ModuleIOContract and log protocol compliance status.
+
+#### Scenario: Registration checks for protocol implementation
+- **WHEN** module package is registered
+- **THEN** system SHALL inspect module for ModuleIOContract implementation
+- **AND** SHALL use hasattr() to check for import_to_bundle, export_from_bundle, sync_with_bundle, validate_bundle methods
+
+#### Scenario: Full protocol implementation is logged
+- **WHEN** module implements all four ModuleIOContract methods
+- **THEN** registration SHALL log at INFO level: "Module X: ModuleIOContract fully implemented"
+- **AND** SHALL store protocol_operations: ["import", "export", "sync", "validate"] in metadata
+
+#### Scenario: Partial protocol implementation is logged with operations
+- **WHEN** module implements only import_to_bundle and validate_bundle
+- **THEN** registration SHALL log at INFO level: "Module X: ModuleIOContract partial (import, validate)"
+- **AND** SHALL store protocol_operations: ["import", "validate"] in metadata
+
+#### Scenario: No protocol implementation logs legacy mode
+- **WHEN** module does not implement any ModuleIOContract methods
+- **THEN** registration SHALL log at WARNING level: "Module X: No ModuleIOContract (legacy mode)"
+- **AND** SHALL store protocol_operations: [] in metadata
+- **AND** module SHALL still be registered for backward compatibility
+
+### Requirement: ProjectBundle schema version compatibility check
+
+The system SHALL extend registration validation to check ProjectBundle schema version compatibility if module declares schema_version in manifest.
+
+#### Scenario: Compatible schema version allows registration
+- **WHEN** module declares schema_version: "1" and ProjectBundle.schema_version is "1"
+- **THEN** registration SHALL succeed
+- **AND** SHALL log: "Module X: Schema version 1 (compatible)"
+
+#### Scenario: Incompatible schema version skips registration
+- **WHEN** module declares schema_version: "2" and ProjectBundle.schema_version is "1"
+- **THEN** registration SHALL skip module
+- **AND** SHALL log at WARNING level: "Module X: Schema version 2 required, but current is 1 (skipped)"
+- **AND** skipped module SHALL be listed in registration summary
+
+#### Scenario: Missing schema version assumes compatibility
+- **WHEN** module omits schema_version from manifest
+- **THEN** registration SHALL assume current ProjectBundle schema
+- **AND** SHALL log at DEBUG level: "Module X: No schema version declared (assuming current)"
+- **AND** module SHALL be registered normally
+
+### Requirement: Registration summary includes protocol compliance
+
+The system SHALL extend registration summary output to include protocol compliance statistics.
+
+#### Scenario: Summary counts protocol-compliant modules
+- **WHEN** registration completes
+- **THEN** summary SHALL include counts: "Protocol-compliant: 4/5 modules"
+- **AND** SHALL list modules by status: Full (3), Partial (1), Legacy (1)
+
+#### Scenario: Summary warns about legacy modules
+- **WHEN** registration finds modules without ModuleIOContract
+- **THEN** summary SHALL include warning: "1 module(s) in legacy mode (no ModuleIOContract)"
+- **AND** SHALL recommend updating to ModuleIOContract for marketplace compatibility
diff --git a/openspec/changes/arch-04-core-contracts-interfaces/specs/module-packages/spec.md b/openspec/changes/arch-04-core-contracts-interfaces/specs/module-packages/spec.md
new file mode 100644
index 00000000..2ccc040b
--- /dev/null
+++ b/openspec/changes/arch-04-core-contracts-interfaces/specs/module-packages/spec.md
@@ -0,0 +1,60 @@
+# Spec: Module Packages (Delta)
+
+## ADDED Requirements
+
+### Requirement: Module package metadata includes schema_version field
+
+The system SHALL extend `ModulePackageMetadata` to include a `schema_version` field indicating which ProjectBundle schema version the module is compatible with.
+
+#### Scenario: Metadata declares schema compatibility
+- **WHEN** module-package.yaml is loaded
+- **THEN** it MAY include `schema_version: "1"` field
+- **AND** module registration SHALL validate compatibility with ProjectBundle.schema_version
+
+#### Scenario: Missing schema_version defaults to current
+- **WHEN** module-package.yaml omits schema_version
+- **THEN** registration SHALL assume current ProjectBundle schema version
+- **AND** SHALL log warning recommending explicit declaration
+
+#### Scenario: Incompatible schema_version blocks registration
+- **WHEN** module declares schema_version: "2" but ProjectBundle is version "1"
+- **THEN** registration SHALL skip module with warning
+- **AND** SHALL log: "Module X requires schema version 2, but current is 1"
+
+### Requirement: Module discovery validates ModuleIOContract implementation
+
+The system SHALL extend module discovery to check if module implements ModuleIOContract protocol and log supported operations.
+
+#### Scenario: Discovery detects protocol implementation
+- **WHEN** module package is discovered and loaded
+- **THEN** registry SHALL check if module class implements ModuleIOContract
+- **AND** SHALL use hasattr() to detect which operations are supported
+
+#### Scenario: Module with protocol is logged as compliant
+- **WHEN** module implements all four ModuleIOContract methods
+- **THEN** registration SHALL log: "Module X implements ModuleIOContract (full)"
+- **AND** SHALL store supported operations in module metadata
+
+#### Scenario: Module without protocol is logged as legacy
+- **WHEN** module does not implement ModuleIOContract
+- **THEN** registration SHALL log warning: "Module X does not implement ModuleIOContract (legacy mode)"
+- **AND** SHALL still register module for backward compatibility
+
+#### Scenario: Module with partial protocol is logged with operations
+- **WHEN** module implements import_to_bundle and validate_bundle only
+- **THEN** registration SHALL log: "Module X implements ModuleIOContract (partial: import, validate)"
+- **AND** SHALL allow partial implementation
+
+### Requirement: Module metadata schema updated in models
+
+The system SHALL update `src/specfact_cli/models/module_package.py` to include schema_version and protocol_compliance fields.
+
+#### Scenario: ModulePackageMetadata has schema_version field
+- **WHEN** ModulePackageMetadata is instantiated
+- **THEN** it SHALL have optional `schema_version: str | None` field
+- **AND** default value SHALL be None (implying current schema)
+
+#### Scenario: ModulePackageMetadata tracks protocol operations
+- **WHEN** module is discovered
+- **THEN** metadata SHALL have `protocol_operations: list[str]` field
+- **AND** SHALL contain names of implemented operations: ["import", "export", "sync", "validate"]
diff --git a/openspec/changes/arch-04-core-contracts-interfaces/tasks.md b/openspec/changes/arch-04-core-contracts-interfaces/tasks.md
new file mode 100644
index 00000000..5e35417d
--- /dev/null
+++ b/openspec/changes/arch-04-core-contracts-interfaces/tasks.md
@@ -0,0 +1,278 @@
+# Tasks: Core Contracts and Module Interface Formalization
+
+## TDD / SDD Order (Enforced)
+
+Per `openspec/config.yaml`, development discipline for SpecFact CLI follows strict SDD+TDD order:
+
+1. **Specs first** — Spec deltas define behavior (Given/When/Then). Already completed in `specs/` directory.
+2. **Tests second** — Write unit/integration tests from spec scenarios (one or more tests per scenario); run tests and expect failure (no implementation yet).
+3. **Code last** — Implement until tests pass and behavior satisfies the spec. Code must satisfy both (a) spec scenarios and (b) tests.
+
+**Do not implement production code until tests exist and have been run (expecting failure).**
+
+Tests MUST come before implementation tasks in each section below.
+
+---
+
+## 1. Create git branch from dev
+
+- [x] 1.1 Ensure on dev and up to date; create branch `feature/arch-04-core-contracts-interfaces`; verify
+ - [x] 1.1.1 `git checkout dev && git pull origin dev`
+ - [x] 1.1.2 `gh issue develop --repo nold-ai/specfact-cli --name feature/arch-04-core-contracts-interfaces --checkout` (if issue exists)
+ - [x] 1.1.3 Or: `git checkout -b feature/arch-04-core-contracts-interfaces` (if no issue)
+ - [x] 1.1.4 `git branch --show-current`
+
+## 2. Foundation: Create contracts directory and protocol definition
+
+- [x] 2.1 Create `src/specfact_cli/contracts/` directory
+- [x] 2.2 Create `src/specfact_cli/contracts/__init__.py` (empty or exports)
+
+## 3. Tests: ModuleIOContract protocol (TDD - tests before implementation)
+
+- [x] 3.1 Create `tests/unit/contracts/` directory
+- [x] 3.2 Create `tests/unit/contracts/test_module_io_contract.py` with test cases from spec `module-io-contract`:
+ - [x] 3.2.1 `test_protocol_defines_four_operations()` - verify Protocol has import_to_bundle, export_from_bundle, sync_with_bundle, validate_bundle
+ - [x] 3.2.2 `test_module_without_inheritance_satisfies_protocol()` - structural subtyping test
+ - [x] 3.2.3 `test_module_with_partial_implementation_type_checked()` - partial protocol compliance
+ - [x] 3.2.4 `test_validation_report_model_structure()` - ValidationReport fields
+- [x] 3.3 Run tests: `pytest tests/unit/contracts/test_module_io_contract.py -v`
+- [x] 3.4 **EXPECT FAILURE** - ModuleIOContract and ValidationReport don't exist yet
+
+## 4. Implementation: ModuleIOContract protocol and ValidationReport model
+
+- [x] 4.1 Create `src/specfact_cli/contracts/module_interface.py` with:
+ - [x] 4.1.1 Import Protocol, abstractmethod, Path from typing/abc/pathlib
+ - [x] 4.1.2 Define `ModuleIOContract` Protocol with four methods: import_to_bundle, export_from_bundle, sync_with_bundle, validate_bundle
+ - [x] 4.1.3 Add type hints using ProjectBundle from models.project
+ - [x] 4.1.4 Add docstrings explaining each operation
+- [x] 4.2 Create `ValidationReport` Pydantic model in `src/specfact_cli/models/validation.py`:
+ - [x] 4.2.1 Add `status` field with Literal["passed", "failed", "warnings"]
+ - [x] 4.2.2 Add `violations` field as list[dict] with severity/message/location
+ - [x] 4.2.3 Add `summary` field as dict with total_checks/passed/failed/warnings counts
+ - [x] 4.2.4 Add @beartype decorator
+- [x] 4.3 Export ValidationReport from `src/specfact_cli/contracts/__init__.py`
+- [x] 4.4 Run tests: `pytest tests/unit/contracts/test_module_io_contract.py -v`
+- [x] 4.5 **EXPECT PASS** - All protocol tests should pass
+
+## 5. Tests: Core module isolation static analysis (TDD - tests before implementation)
+
+- [x] 5.1 Create `tests/unit/test_core_module_isolation.py` with test cases from spec `core-module-isolation`:
+ - [x] 5.1.1 `test_core_has_no_module_imports()` - scan core dirs, fail on `specfact_cli.modules.*` imports
+ - [x] 5.1.2 `test_excludes_type_checking_blocks()` - allow imports in `if TYPE_CHECKING:`
+ - [x] 5.1.3 `test_multiple_violations_reported_together()` - aggregate violations before failing
+ - [x] 5.1.4 `test_violation_message_format()` - verify message includes file:line and module name
+ - [x] 5.1.5 Add CORE_DIRS constant: cli.py, registry/, models/, utils/, contracts/
+- [x] 5.2 Run test: `pytest tests/unit/test_core_module_isolation.py -v`
+- [x] 5.3 **EXPECT PASS** - Should pass initially (no violations in current code)
+
+## 6. Implementation: Core module isolation enforcement
+
+- [x] 6.1 Add helper function `_collect_python_files(dirs: list[Path]) -> list[Path]` to isolation test
+- [x] 6.2 Add helper function `_get_module_name(node: ast.Node) -> str` to extract import module name
+- [x] 6.3 Add helper function `_is_in_type_checking_block(node: ast.Node, tree: ast.AST) -> bool`
+- [x] 6.4 Implement AST parsing logic in `test_core_has_no_module_imports()`:
+ - [x] 6.4.1 Walk through AST nodes looking for Import and ImportFrom
+ - [x] 6.4.2 Skip nodes within TYPE_CHECKING blocks
+ - [x] 6.4.3 Collect violations with file:line:module format
+ - [x] 6.4.4 Assert no violations with clear error message
+- [x] 6.5 Add `.github/workflows/tests.yml` step for isolation test (if not already covered by pytest all)
+- [x] 6.6 Run test: `pytest tests/unit/test_core_module_isolation.py -v`
+- [x] 6.7 **EXPECT PASS** - Isolation test should pass on clean code
+
+## 7. Tests: ProjectBundle schema versioning (TDD - tests before implementation)
+
+- [x] 7.1 Create `tests/unit/models/test_project_bundle_schema.py` with tests:
+ - [x] 7.1.1 `test_project_bundle_has_schema_version()` - verify schema_version field exists with default "1"
+ - [x] 7.1.2 `test_schema_version_can_be_set()` - create ProjectBundle with custom schema_version
+ - [x] 7.1.3 `test_schema_version_validation()` - verify Pydantic validates schema_version as string
+- [x] 7.2 Run tests: `pytest tests/unit/models/test_project_bundle_schema.py -v`
+- [x] 7.3 **EXPECT FAILURE** - schema_version field doesn't exist yet
+
+## 8. Implementation: ProjectBundle schema versioning
+
+- [x] 8.1 Add `schema_version: str = "1"` field to ProjectBundle in `src/specfact_cli/models/project.py`
+- [x] 8.2 Add docstring explaining schema versioning strategy
+- [x] 8.3 Update ProjectBundle Field(...) with description for schema_version
+- [x] 8.4 Run tests: `pytest tests/unit/models/test_project_bundle_schema.py -v`
+- [x] 8.5 **EXPECT PASS** - Schema version tests should pass
+
+## 9. Tests: Module package metadata extensions (TDD - tests before implementation)
+
+- [x] 9.1 Create `tests/unit/models/test_module_package_metadata.py` with tests from spec `module-packages`:
+ - [x] 9.1.1 `test_metadata_includes_schema_version()` - verify schema_version optional field
+ - [x] 9.1.2 `test_metadata_includes_protocol_operations()` - verify protocol_operations list field
+ - [x] 9.1.3 `test_metadata_schema_version_defaults_to_none()` - default value test
+ - [x] 9.1.4 `test_protocol_operations_defaults_to_empty()` - default value test
+- [x] 9.2 Run tests: `pytest tests/unit/models/test_module_package_metadata.py -v`
+- [x] 9.3 **EXPECT FAILURE** - New fields don't exist yet
+
+## 10. Implementation: Module package metadata extensions
+
+- [x] 10.1 Update `src/specfact_cli/models/module_package.py` ModulePackageMetadata:
+ - [x] 10.1.1 Add `schema_version: str | None = None` field
+ - [x] 10.1.2 Add `protocol_operations: list[str] = Field(default_factory=list)` field
+ - [x] 10.1.3 Add docstrings for new fields
+ - [x] 10.1.4 Add @beartype decorator if not already present
+- [x] 10.2 Run tests: `pytest tests/unit/models/test_module_package_metadata.py -v`
+- [x] 10.3 **EXPECT PASS** - Metadata extension tests should pass
+
+## 11. Tests: Module discovery protocol validation (TDD - tests before implementation)
+
+- [x] 11.1 Create `tests/unit/registry/test_module_protocol_validation.py` with tests from specs:
+ - [x] 11.1.1 `test_discovery_detects_protocol_implementation()` - hasattr checks for ModuleIOContract methods
+ - [x] 11.1.2 `test_full_protocol_logged()` - all four methods present
+ - [x] 11.1.3 `test_partial_protocol_logged()` - subset of methods present
+ - [x] 11.1.4 `test_no_protocol_legacy_mode()` - no methods present
+ - [x] 11.1.5 `test_schema_version_compatibility_check()` - compatible/incompatible/missing scenarios
+ - [x] 11.1.6 Mock module classes with various protocol implementations
+- [x] 11.2 Run tests: `pytest tests/unit/registry/test_module_protocol_validation.py -v`
+- [x] 11.3 **EXPECT FAILURE** - Protocol validation not implemented yet
+
+## 12. Implementation: Module discovery protocol validation
+
+- [x] 12.1 Update `src/specfact_cli/registry/module_packages.py`:
+ - [x] 12.1.1 Add helper `_check_protocol_compliance(module_class: type) -> list[str]` that checks hasattr for four methods
+ - [x] 12.1.2 Add helper `_check_schema_compatibility(module_schema: str | None, current: str) -> bool`
+ - [x] 12.1.3 Update `register_module_package_commands()` to call protocol checks
+ - [x] 12.1.4 Store protocol_operations in metadata after detection
+ - [x] 12.1.5 Log INFO/WARNING based on protocol compliance
+ - [x] 12.1.6 Skip registration if schema incompatible
+ - [x] 12.1.7 Add @beartype and @icontract decorators to new functions
+- [x] 12.2 Run tests: `pytest tests/unit/registry/test_module_protocol_validation.py -v`
+- [x] 12.3 **EXPECT PASS** - Protocol validation tests should pass
+
+## 13. Tests: Module implementation updates (TDD - tests before implementation)
+
+- [x] 13.1 For each module (backlog, sync, plan, generate, enforce), create test in `tests/unit/modules//test_module_io_contract.py`:
+ - [x] 13.1.1 `test_module_implements_protocol()` - verify hasattr for ModuleIOContract methods
+ - [x] 13.1.2 `test_import_to_bundle_signature()` - verify method signature matches protocol
+ - [x] 13.1.3 `test_export_from_bundle_signature()` - verify method signature matches protocol
+ - [x] 13.1.4 `test_methods_have_contracts()` - verify @icontract and @beartype decorators present
+- [x] 13.2 Run tests: `pytest tests/unit/modules/ -k test_module_io_contract -v`
+- [x] 13.3 **EXPECT FAILURE** - Modules don't implement ModuleIOContract yet
+
+## 14. Implementation: Update backlog module (template for others)
+
+- [x] 14.1 Update `src/specfact_cli/modules/backlog/src/commands.py`:
+ - [x] 14.1.1 Add ModuleIOContract import from contracts.module_interface
+ - [x] 14.1.2 Implement import_to_bundle method with @icontract @require/@ensure and @beartype
+ - [x] 14.1.3 Implement export_from_bundle method with contracts
+ - [x] 14.1.4 Implement sync_with_bundle method with contracts
+ - [x] 14.1.5 Implement validate_bundle method with contracts
+ - [x] 14.1.6 Add docstrings for each method
+- [x] 14.2 Run tests: `pytest tests/unit/modules/backlog/test_module_io_contract.py -v`
+- [x] 14.3 **EXPECT PASS** - Backlog module protocol tests should pass
+
+## 15. Implementation: Update remaining modules (sync, plan, generate, enforce)
+
+- [x] 15.1 Update sync module following backlog template (tasks 14.1.1-14.1.6)
+- [x] 15.2 Update plan module following backlog template
+- [x] 15.3 Update generate module following backlog template
+- [x] 15.4 Update enforce module following backlog template
+- [x] 15.5 Run tests: `pytest tests/unit/modules/ -k test_module_io_contract -v`
+- [x] 15.6 **EXPECT PASS** - All module protocol tests should pass
+
+## 16. Quality gates and validation
+
+- [x] 16.1 Run formatters: `hatch run format`
+- [x] 16.2 Run type checking: `hatch run type-check` (expect no errors)
+- [x] 16.3 Run contract tests: `hatch run contract-test` (CrossHair symbolic execution)
+- [ ] 16.4 Run full test suite: `hatch test --cover -v` (expect >80% coverage)
+- [ ] 16.5 Run linting: `hatch run lint` (expect no errors)
+- [x] 16.6 Validate OpenSpec change: `openspec validate arch-04-core-contracts-interfaces --strict`
+
+## 17. Documentation research and review
+
+- [x] 17.1 Identify affected documentation:
+ - [x] 17.1.1 List files: `docs/reference/`, `docs/guides/`, `README.md`, `docs/index.md`, `docs/_layouts/default.html`
+- [x] 17.2 Create `docs/reference/projectbundle-schema.md`:
+ - [x] 17.2.1 Add Jekyll front-matter (layout: default, title: ProjectBundle Schema, permalink, description)
+ - [x] 17.2.2 Document ProjectBundle fields, schema_version, and versioning strategy
+ - [x] 17.2.3 Include examples of ProjectBundle with schema version
+ - [x] 17.2.4 Explain backward compatibility approach
+- [x] 17.3 Create `docs/reference/module-contracts.md`:
+ - [x] 17.3.1 Add Jekyll front-matter
+ - [x] 17.3.2 Document ModuleIOContract protocol with four operations
+ - [x] 17.3.3 Provide code examples of implementing the protocol
+ - [x] 17.3.4 Explain inversion-of-control architecture (core never imports modules)
+ - [x] 17.3.5 Include guidance for 3rd-party module developers
+ - [x] 17.3.6 Document ValidationReport structure
+- [x] 17.4 Update `docs/reference/architecture.md`:
+ - [x] 17.4.1 Add section on contract-first module development
+ - [x] 17.4.2 Explain core-module isolation principle
+ - [x] 17.4.3 Reference ModuleIOContract protocol
+- [x] 17.5 Update `docs/_layouts/default.html` sidebar navigation:
+ - [x] 17.5.1 Add link to ProjectBundle Schema under Reference section
+ - [x] 17.5.2 Add link to Module Contracts under Reference section
+- [x] 17.6 Update `README.md`:
+ - [x] 17.6.1 Add brief mention of contract-first module architecture (if relevant to main intro)
+- [ ] 17.7 Run documentation link checker: `markdownlint --config .markdownlint.json docs/`
+- [x] 17.8 Verify docs render correctly at (local preview with Jekyll)
+
+## 18. Version and changelog
+
+- [x] 18.1 Determine version bump (minor version for new feature: arch-04 adds contracts)
+- [x] 18.2 Update version in `pyproject.toml`
+- [x] 18.3 Update version in `setup.py`
+- [x] 18.4 Update version in `src/__init__.py`
+- [x] 18.5 Update version in `src/specfact_cli/__init__.py`
+- [x] 18.6 Add CHANGELOG.md entry under new version section:
+ - [x] 18.6.1 Section: `[X.Y.Z] - 2026-02-XX` (use actual date)
+ - [x] 18.6.2 `### Added (X.Y.Z)` subsection with:
+ - ModuleIOContract protocol for formal module interfaces
+ - Static analysis enforcement of core-module isolation
+ - ProjectBundle schema versioning (schema_version field)
+ - ValidationReport model for structured validation results
+ - Protocol compliance tracking in module metadata
+ - [x] 18.6.3 `### Changed (X.Y.Z)` subsection:
+ - Updated 5 modules (backlog, sync, plan, generate, enforce) to implement ModuleIOContract
+ - [x] 18.6.4 Reference GitHub issue: `(fixes #)`
+
+## 19. GitHub issue creation
+
+- [x] 19.1 Create GitHub issue in nold-ai/specfact-cli:
+ - [x] 19.1.1 Title: `[Change] Core Contracts and Module Interface Formalization`
+ - [x] 19.1.2 Labels: `enhancement`, `change-proposal`
+ - [x] 19.1.3 Body from proposal.md: Why, What Changes sections
+ - [x] 19.1.4 Add acceptance criteria from proposal Impact section
+ - [x] 19.1.5 Footer: `*OpenSpec Change Proposal: arch-04-core-contracts-interfaces*`
+ - [x] 19.1.6 Create: `gh issue create --repo nold-ai/specfact-cli --title "..." --body-file /tmp/issue-arch-04.md --label enhancement --label change-proposal`
+- [x] 19.2 Link issue to project: `gh project item-add 1 --owner nold-ai --url `
+- [x] 19.3 Update `proposal.md` Source Tracking section with issue number and URL
+
+## 20. Create pull request to dev
+
+- [x] 20.1 Prepare commit:
+ - [x] 20.1.1 `git add .`
+ - [x] 20.1.2 Commit with conventional message:
+
+ ```bash
+ git commit -m "$(cat <<'EOF'
+ feat: add ModuleIOContract protocol and core-module isolation
+
+ - Create ModuleIOContract protocol with four core operations
+ - Add static analysis enforcement preventing core→module imports
+ - Add ProjectBundle schema versioning (schema_version field)
+ - Update 5 modules to implement ModuleIOContract
+ - Add protocol compliance tracking in module discovery
+ - Create docs for ProjectBundle schema and module contracts
+
+ Co-Authored-By: Claude Sonnet 4.5
+ EOF
+ )"
+ ```
+
+ - [x] 20.1.3 `git push origin feature/arch-04-core-contracts-interfaces`
+- [x] 20.2 Create PR body from `.github/pull_request_template.md`:
+ - [x] 20.2.1 Use full repo path for issue ref: `Fixes nold-ai/specfact-cli#`
+ - [x] 20.2.2 Include OpenSpec change ID in description
+ - [x] 20.2.3 List key deliverables: protocol, isolation test, schema versioning, module updates, docs
+- [x] 20.3 Create PR:
+
+ ```bash
+ gh pr create --repo nold-ai/specfact-cli --base dev --head feature/arch-04-core-contracts-interfaces --title "feat: Core Contracts and Module Interface Formalization" --body-file /tmp/pr-body-arch-04.md
+ ```
+
+- [x] 20.4 Link PR to project: `gh project item-add 1 --owner nold-ai --url `
+- [x] 20.5 Verify Development link appears on GitHub issue
+- [x] 20.6 Update project board status to "In Progress" (if applicable)
diff --git a/pyproject.toml b/pyproject.toml
index 419251e1..1ab3899b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "specfact-cli"
-version = "0.29.0"
+version = "0.30.0"
description = "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with validation and contract enforcement for new projects and long-lived codebases."
readme = "README.md"
requires-python = ">=3.11"
diff --git a/setup.py b/setup.py
index a2e2187b..85216c9c 100644
--- a/setup.py
+++ b/setup.py
@@ -7,7 +7,7 @@
if __name__ == "__main__":
_setup = setup(
name="specfact-cli",
- version="0.29.0",
+ version="0.30.0",
description=(
"The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with "
"validation and contract enforcement for new projects and long-lived codebases."
diff --git a/src/__init__.py b/src/__init__.py
index 0b8990ac..74262ce0 100644
--- a/src/__init__.py
+++ b/src/__init__.py
@@ -3,4 +3,4 @@
"""
# Package version: keep in sync with pyproject.toml, setup.py, src/specfact_cli/__init__.py
-__version__ = "0.29.0"
+__version__ = "0.30.0"
diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py
index d0f19675..15b4ec13 100644
--- a/src/specfact_cli/__init__.py
+++ b/src/specfact_cli/__init__.py
@@ -8,6 +8,6 @@
- Supporting agile ceremonies and team workflows
"""
-__version__ = "0.29.0"
+__version__ = "0.30.0"
__all__ = ["__version__"]
diff --git a/src/specfact_cli/contracts/__init__.py b/src/specfact_cli/contracts/__init__.py
index e4a5ab7d..3e5f0af2 100644
--- a/src/specfact_cli/contracts/__init__.py
+++ b/src/specfact_cli/contracts/__init__.py
@@ -1,3 +1,6 @@
-"""Contract property modules for CrossHair analysis."""
+"""Contract exports for protocol and validation integrations."""
-__all__ = ["crosshair_props"]
+from specfact_cli.models.validation import ValidationReport
+
+
+__all__ = ["ValidationReport", "crosshair_props"]
diff --git a/src/specfact_cli/contracts/module_interface.py b/src/specfact_cli/contracts/module_interface.py
new file mode 100644
index 00000000..28ba2967
--- /dev/null
+++ b/src/specfact_cli/contracts/module_interface.py
@@ -0,0 +1,30 @@
+"""Module IO protocol contract for module bundle interactions."""
+
+from __future__ import annotations
+
+from abc import abstractmethod
+from pathlib import Path
+from typing import Any, Protocol
+
+from specfact_cli.models.project import ProjectBundle
+from specfact_cli.models.validation import ValidationReport
+
+
+class ModuleIOContract(Protocol):
+ """Protocol for module implementations that exchange data via ProjectBundle."""
+
+ @abstractmethod
+ def import_to_bundle(self, source: Path, config: dict[str, Any]) -> ProjectBundle:
+ """Import an external artifact and convert it into a ProjectBundle."""
+
+ @abstractmethod
+ def export_from_bundle(self, bundle: ProjectBundle, target: Path, config: dict[str, Any]) -> None:
+ """Export a ProjectBundle to an external artifact format."""
+
+ @abstractmethod
+ def sync_with_bundle(self, bundle: ProjectBundle, external_source: str, config: dict[str, Any]) -> ProjectBundle:
+ """Synchronize a bundle with an external source and return the updated bundle."""
+
+ @abstractmethod
+ def validate_bundle(self, bundle: ProjectBundle, rules: dict[str, Any]) -> ValidationReport:
+ """Run module-specific validation on a ProjectBundle."""
diff --git a/src/specfact_cli/models/module_package.py b/src/specfact_cli/models/module_package.py
new file mode 100644
index 00000000..016ec018
--- /dev/null
+++ b/src/specfact_cli/models/module_package.py
@@ -0,0 +1,35 @@
+"""Module package metadata models."""
+
+from __future__ import annotations
+
+from beartype import beartype
+from pydantic import BaseModel, Field
+
+
+@beartype
+class ModulePackageMetadata(BaseModel):
+ """Schema for a module package manifest."""
+
+ name: str = Field(..., description="Package identifier (e.g. backlog_refine)")
+ version: str = Field(default="0.1.0", description="Package version")
+ commands: list[str] = Field(default_factory=list, description="Command names this package provides")
+ command_help: dict[str, str] | None = Field(
+ default=None,
+ description="Optional command name -> help text for root help.",
+ )
+ pip_dependencies: list[str] = Field(default_factory=list, description="Optional pip dependencies")
+ module_dependencies: list[str] = Field(default_factory=list, description="Optional other package ids")
+ core_compatibility: str | None = Field(
+ default=None,
+ description="CLI core version compatibility (PEP 440 specifier, e.g. '>=0.28.0,<1.0.0').",
+ )
+ tier: str = Field(default="community", description="Tier: community or enterprise")
+ addon_id: str | None = Field(default=None, description="Optional addon identifier")
+ schema_version: str | None = Field(
+ default=None,
+ description="Compatible ProjectBundle schema version. None means current schema.",
+ )
+ protocol_operations: list[str] = Field(
+ default_factory=list,
+ description="Detected ModuleIOContract operations: import, export, sync, validate.",
+ )
diff --git a/src/specfact_cli/models/project.py b/src/specfact_cli/models/project.py
index 6a98f214..60affa96 100644
--- a/src/specfact_cli/models/project.py
+++ b/src/specfact_cli/models/project.py
@@ -19,7 +19,7 @@
from beartype import beartype
from icontract import ensure, require
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, StrictStr
from specfact_cli.models.change import ChangeArchive, ChangeProposal, ChangeTracking, FeatureDelta
from specfact_cli.models.contract import ContractIndex
@@ -173,10 +173,18 @@ class BundleManifest(BaseModel):
class ProjectBundle(BaseModel):
- """Modular project bundle (replaces monolithic PlanBundle)."""
+ """Modular project bundle (replaces monolithic PlanBundle).
+
+ The ``schema_version`` field tracks module IO compatibility independently
+ from manifest schema evolution to support forward-compatible module loading.
+ """
manifest: BundleManifest = Field(..., description="Bundle manifest with metadata")
bundle_name: str = Field(..., description="Project bundle name (directory name, e.g., 'legacy-api')")
+ schema_version: StrictStr = Field(
+ default="1",
+ description="ProjectBundle IO schema version used by module contracts for compatibility checks.",
+ )
idea: Idea | None = None
business: Business | None = None
product: Product = Field(..., description="Product definition")
diff --git a/src/specfact_cli/models/validation.py b/src/specfact_cli/models/validation.py
new file mode 100644
index 00000000..ece1e331
--- /dev/null
+++ b/src/specfact_cli/models/validation.py
@@ -0,0 +1,31 @@
+"""Validation models used by module IO contracts."""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from beartype import beartype
+from pydantic import BaseModel, Field
+
+
+@beartype
+class ValidationReport(BaseModel):
+ """Structured validation report for module-level bundle validation."""
+
+ status: Literal["passed", "failed", "warnings"] = Field(
+ default="passed",
+ description="Validation result status.",
+ )
+ violations: list[dict[str, str]] = Field(
+ default_factory=list,
+ description="Validation violations with severity/message/location keys.",
+ )
+ summary: dict[str, int] = Field(
+ default_factory=lambda: {
+ "total_checks": 0,
+ "passed": 0,
+ "failed": 0,
+ "warnings": 0,
+ },
+ description="Validation summary counters.",
+ )
diff --git a/src/specfact_cli/modules/backlog/src/commands.py b/src/specfact_cli/modules/backlog/src/commands.py
index 76f46d66..5794e9db 100644
--- a/src/specfact_cli/modules/backlog/src/commands.py
+++ b/src/specfact_cli/modules/backlog/src/commands.py
@@ -39,8 +39,12 @@
from specfact_cli.backlog.ai_refiner import BacklogAIRefiner
from specfact_cli.backlog.filters import BacklogFilters
from specfact_cli.backlog.template_detector import TemplateDetector
+from specfact_cli.contracts.module_interface import ModuleIOContract
from specfact_cli.models.backlog_item import BacklogItem
from specfact_cli.models.dor_config import DefinitionOfReady
+from specfact_cli.models.plan import Product
+from specfact_cli.models.project import BundleManifest, ProjectBundle
+from specfact_cli.models.validation import ValidationReport
from specfact_cli.runtime import debug_log_operation, is_debug_mode
from specfact_cli.templates.registry import TemplateRegistry
@@ -51,6 +55,70 @@
context_settings={"help_option_names": ["-h", "--help"]},
)
console = Console()
+_MODULE_IO_CONTRACT = ModuleIOContract
+
+
+@beartype
+@require(lambda source: source.exists(), "Source path must exist")
+@ensure(lambda result: isinstance(result, ProjectBundle), "Must return ProjectBundle")
+def import_to_bundle(source: Path, config: dict[str, Any]) -> ProjectBundle:
+ """Convert external source artifacts into a ProjectBundle."""
+ if source.is_dir() and (source / "bundle.manifest.yaml").exists():
+ return ProjectBundle.load_from_directory(source)
+ bundle_name = config.get("bundle_name", source.stem if source.suffix else source.name)
+ return ProjectBundle(
+ manifest=BundleManifest(schema_metadata=None, project_metadata=None),
+ bundle_name=str(bundle_name),
+ product=Product(),
+ )
+
+
+@beartype
+@require(lambda target: target is not None, "Target path must be provided")
+@ensure(lambda target: target.exists(), "Target must exist after export")
+def export_from_bundle(bundle: ProjectBundle, target: Path, config: dict[str, Any]) -> None:
+ """Export a ProjectBundle to target path."""
+ if target.suffix:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(bundle.model_dump_json(indent=2), encoding="utf-8")
+ return
+ target.mkdir(parents=True, exist_ok=True)
+ bundle.save_to_directory(target)
+
+
+@beartype
+@require(lambda external_source: len(external_source.strip()) > 0, "External source must be non-empty")
+@ensure(lambda result: isinstance(result, ProjectBundle), "Must return ProjectBundle")
+def sync_with_bundle(bundle: ProjectBundle, external_source: str, config: dict[str, Any]) -> ProjectBundle:
+ """Synchronize an existing bundle with an external source."""
+ source_path = Path(external_source)
+ if source_path.exists() and source_path.is_dir() and (source_path / "bundle.manifest.yaml").exists():
+ return ProjectBundle.load_from_directory(source_path)
+ return bundle
+
+
+@beartype
+@ensure(lambda result: isinstance(result, ValidationReport), "Must return ValidationReport")
+def validate_bundle(bundle: ProjectBundle, rules: dict[str, Any]) -> ValidationReport:
+ """Validate bundle for module-specific constraints."""
+ total_checks = max(len(rules), 1)
+ report = ValidationReport(
+ status="passed",
+ violations=[],
+ summary={"total_checks": total_checks, "passed": total_checks, "failed": 0, "warnings": 0},
+ )
+ if not bundle.bundle_name:
+ report.status = "failed"
+ report.violations.append(
+ {
+ "severity": "error",
+ "message": "Bundle name is required",
+ "location": "ProjectBundle.bundle_name",
+ }
+ )
+ report.summary["failed"] += 1
+ report.summary["passed"] = max(report.summary["passed"] - 1, 0)
+ return report
def _apply_filters(
diff --git a/src/specfact_cli/modules/enforce/src/commands.py b/src/specfact_cli/modules/enforce/src/commands.py
index 2e41c06b..6ad1a85c 100644
--- a/src/specfact_cli/modules/enforce/src/commands.py
+++ b/src/specfact_cli/modules/enforce/src/commands.py
@@ -9,16 +9,21 @@
from datetime import datetime
from pathlib import Path
+from typing import Any
import typer
from beartype import beartype
-from icontract import require
+from icontract import ensure, require
from rich.console import Console
from rich.table import Table
+from specfact_cli.contracts.module_interface import ModuleIOContract
from specfact_cli.models.deviation import Deviation, DeviationSeverity, DeviationType, ValidationReport
from specfact_cli.models.enforcement import EnforcementConfig, EnforcementPreset
+from specfact_cli.models.plan import Product
+from specfact_cli.models.project import BundleManifest, ProjectBundle
from specfact_cli.models.sdd import SDDManifest
+from specfact_cli.models.validation import ValidationReport as ModuleValidationReport
from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode
from specfact_cli.telemetry import telemetry
from specfact_cli.utils.structure import SpecFactStructure
@@ -27,6 +32,71 @@
app = typer.Typer(help="Configure quality gates and enforcement modes")
console = Console()
+_MODULE_IO_CONTRACT = ModuleIOContract
+
+
+@beartype
+@require(lambda source: source.exists(), "Source path must exist")
+@ensure(lambda result: isinstance(result, ProjectBundle), "Must return ProjectBundle")
+def import_to_bundle(source: Path, config: dict[str, Any]) -> ProjectBundle:
+ """Convert external source artifacts into a ProjectBundle."""
+ if source.is_dir() and (source / "bundle.manifest.yaml").exists():
+ return ProjectBundle.load_from_directory(source)
+ bundle_name = config.get("bundle_name", source.stem if source.suffix else source.name)
+ return ProjectBundle(
+ manifest=BundleManifest(schema_metadata=None, project_metadata=None),
+ bundle_name=str(bundle_name),
+ product=Product(),
+ )
+
+
+@beartype
+@require(lambda target: target is not None, "Target path must be provided")
+@ensure(lambda target: target.exists(), "Target must exist after export")
+def export_from_bundle(bundle: ProjectBundle, target: Path, config: dict[str, Any]) -> None:
+ """Export a ProjectBundle to target path."""
+ if target.suffix:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(bundle.model_dump_json(indent=2), encoding="utf-8")
+ return
+ target.mkdir(parents=True, exist_ok=True)
+ bundle.save_to_directory(target)
+
+
+@beartype
+@require(lambda external_source: len(external_source.strip()) > 0, "External source must be non-empty")
+@ensure(lambda result: isinstance(result, ProjectBundle), "Must return ProjectBundle")
+def sync_with_bundle(bundle: ProjectBundle, external_source: str, config: dict[str, Any]) -> ProjectBundle:
+ """Synchronize an existing bundle with an external source."""
+ source_path = Path(external_source)
+ if source_path.exists() and source_path.is_dir() and (source_path / "bundle.manifest.yaml").exists():
+ return ProjectBundle.load_from_directory(source_path)
+ return bundle
+
+
+@beartype
+@require(lambda rules: isinstance(rules, dict), "Rules must be a dictionary")
+@ensure(lambda result: isinstance(result, ModuleValidationReport), "Must return ValidationReport")
+def validate_bundle(bundle: ProjectBundle, rules: dict[str, Any]) -> ModuleValidationReport:
+ """Validate bundle for module-specific constraints."""
+ total_checks = max(len(rules), 1)
+ report = ModuleValidationReport(
+ status="passed",
+ violations=[],
+ summary={"total_checks": total_checks, "passed": total_checks, "failed": 0, "warnings": 0},
+ )
+ if not bundle.bundle_name:
+ report.status = "failed"
+ report.violations.append(
+ {
+ "severity": "error",
+ "message": "Bundle name is required",
+ "location": "ProjectBundle.bundle_name",
+ }
+ )
+ report.summary["failed"] += 1
+ report.summary["passed"] = max(report.summary["passed"] - 1, 0)
+ return report
@app.command("stage")
diff --git a/src/specfact_cli/modules/generate/src/commands.py b/src/specfact_cli/modules/generate/src/commands.py
index 176e9c62..45e530a9 100644
--- a/src/specfact_cli/modules/generate/src/commands.py
+++ b/src/specfact_cli/modules/generate/src/commands.py
@@ -7,14 +7,19 @@
from __future__ import annotations
from pathlib import Path
+from typing import Any
import typer
from beartype import beartype
from icontract import ensure, require
+from specfact_cli.contracts.module_interface import ModuleIOContract
from specfact_cli.generators.contract_generator import ContractGenerator
from specfact_cli.migrations.plan_migrator import load_plan_bundle
+from specfact_cli.models.plan import Product
+from specfact_cli.models.project import BundleManifest, ProjectBundle
from specfact_cli.models.sdd import SDDManifest
+from specfact_cli.models.validation import ValidationReport
from specfact_cli.runtime import debug_log_operation, debug_print, get_configured_console, is_debug_mode
from specfact_cli.telemetry import telemetry
from specfact_cli.utils import print_error, print_info, print_success, print_warning
@@ -30,6 +35,70 @@
app = typer.Typer(help="Generate artifacts from SDD and plans")
console = get_configured_console()
+_MODULE_IO_CONTRACT = ModuleIOContract
+
+
+@beartype
+@require(lambda source: source.exists(), "Source path must exist")
+@ensure(lambda result: isinstance(result, ProjectBundle), "Must return ProjectBundle")
+def import_to_bundle(source: Path, config: dict[str, Any]) -> ProjectBundle:
+ """Convert external source artifacts into a ProjectBundle."""
+ if source.is_dir() and (source / "bundle.manifest.yaml").exists():
+ return ProjectBundle.load_from_directory(source)
+ bundle_name = config.get("bundle_name", source.stem if source.suffix else source.name)
+ return ProjectBundle(
+ manifest=BundleManifest(schema_metadata=None, project_metadata=None),
+ bundle_name=str(bundle_name),
+ product=Product(),
+ )
+
+
+@beartype
+@require(lambda target: target is not None, "Target path must be provided")
+@ensure(lambda target: target.exists(), "Target must exist after export")
+def export_from_bundle(bundle: ProjectBundle, target: Path, config: dict[str, Any]) -> None:
+ """Export a ProjectBundle to target path."""
+ if target.suffix:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(bundle.model_dump_json(indent=2), encoding="utf-8")
+ return
+ target.mkdir(parents=True, exist_ok=True)
+ bundle.save_to_directory(target)
+
+
+@beartype
+@require(lambda external_source: len(external_source.strip()) > 0, "External source must be non-empty")
+@ensure(lambda result: isinstance(result, ProjectBundle), "Must return ProjectBundle")
+def sync_with_bundle(bundle: ProjectBundle, external_source: str, config: dict[str, Any]) -> ProjectBundle:
+ """Synchronize an existing bundle with an external source."""
+ source_path = Path(external_source)
+ if source_path.exists() and source_path.is_dir() and (source_path / "bundle.manifest.yaml").exists():
+ return ProjectBundle.load_from_directory(source_path)
+ return bundle
+
+
+@beartype
+@ensure(lambda result: isinstance(result, ValidationReport), "Must return ValidationReport")
+def validate_bundle(bundle: ProjectBundle, rules: dict[str, Any]) -> ValidationReport:
+ """Validate bundle for module-specific constraints."""
+ total_checks = max(len(rules), 1)
+ report = ValidationReport(
+ status="passed",
+ violations=[],
+ summary={"total_checks": total_checks, "passed": total_checks, "failed": 0, "warnings": 0},
+ )
+ if not bundle.bundle_name:
+ report.status = "failed"
+ report.violations.append(
+ {
+ "severity": "error",
+ "message": "Bundle name is required",
+ "location": "ProjectBundle.bundle_name",
+ }
+ )
+ report.summary["failed"] += 1
+ report.summary["passed"] = max(report.summary["passed"] - 1, 0)
+ return report
def _show_apply_help() -> None:
diff --git a/src/specfact_cli/modules/plan/src/commands.py b/src/specfact_cli/modules/plan/src/commands.py
index de255d0c..37394468 100644
--- a/src/specfact_cli/modules/plan/src/commands.py
+++ b/src/specfact_cli/modules/plan/src/commands.py
@@ -22,12 +22,14 @@
from specfact_cli import runtime
from specfact_cli.analyzers.ambiguity_scanner import AmbiguityFinding
from specfact_cli.comparators.plan_comparator import PlanComparator
+from specfact_cli.contracts.module_interface import ModuleIOContract
from specfact_cli.generators.report_generator import ReportFormat, ReportGenerator
from specfact_cli.models.deviation import Deviation, DeviationSeverity, DeviationType, ValidationReport
from specfact_cli.models.enforcement import EnforcementConfig
from specfact_cli.models.plan import Business, Feature, Idea, PlanBundle, Product, Release, Story
from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle
from specfact_cli.models.sdd import SDDHow, SDDManifest, SDDWhat, SDDWhy
+from specfact_cli.models.validation import ValidationReport as ModuleValidationReport
from specfact_cli.modes import detect_mode
from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode, is_non_interactive
from specfact_cli.telemetry import telemetry
@@ -50,6 +52,70 @@
app = typer.Typer(help="Manage development plans, features, and stories")
console = Console()
+_MODULE_IO_CONTRACT = ModuleIOContract
+
+
+@beartype
+@require(lambda source: source.exists(), "Source path must exist")
+@ensure(lambda result: isinstance(result, ProjectBundle), "Must return ProjectBundle")
+def import_to_bundle(source: Path, config: dict[str, Any]) -> ProjectBundle:
+ """Convert external source artifacts into a ProjectBundle."""
+ if source.is_dir() and (source / "bundle.manifest.yaml").exists():
+ return ProjectBundle.load_from_directory(source)
+ bundle_name = config.get("bundle_name", source.stem if source.suffix else source.name)
+ return ProjectBundle(
+ manifest=BundleManifest(schema_metadata=None, project_metadata=None),
+ bundle_name=str(bundle_name),
+ product=Product(),
+ )
+
+
+@beartype
+@require(lambda target: target is not None, "Target path must be provided")
+@ensure(lambda target: target.exists(), "Target must exist after export")
+def export_from_bundle(bundle: ProjectBundle, target: Path, config: dict[str, Any]) -> None:
+ """Export a ProjectBundle to target path."""
+ if target.suffix:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(bundle.model_dump_json(indent=2), encoding="utf-8")
+ return
+ target.mkdir(parents=True, exist_ok=True)
+ bundle.save_to_directory(target)
+
+
+@beartype
+@require(lambda external_source: len(external_source.strip()) > 0, "External source must be non-empty")
+@ensure(lambda result: isinstance(result, ProjectBundle), "Must return ProjectBundle")
+def sync_with_bundle(bundle: ProjectBundle, external_source: str, config: dict[str, Any]) -> ProjectBundle:
+ """Synchronize an existing bundle with an external source."""
+ source_path = Path(external_source)
+ if source_path.exists() and source_path.is_dir() and (source_path / "bundle.manifest.yaml").exists():
+ return ProjectBundle.load_from_directory(source_path)
+ return bundle
+
+
+@beartype
+@ensure(lambda result: isinstance(result, ModuleValidationReport), "Must return ValidationReport")
+def validate_bundle(bundle: ProjectBundle, rules: dict[str, Any]) -> ModuleValidationReport:
+ """Validate bundle for module-specific constraints."""
+ total_checks = max(len(rules), 1)
+ report = ModuleValidationReport(
+ status="passed",
+ violations=[],
+ summary={"total_checks": total_checks, "passed": total_checks, "failed": 0, "warnings": 0},
+ )
+ if not bundle.bundle_name:
+ report.status = "failed"
+ report.violations.append(
+ {
+ "severity": "error",
+ "message": "Bundle name is required",
+ "location": "ProjectBundle.bundle_name",
+ }
+ )
+ report.summary["failed"] += 1
+ report.summary["passed"] = max(report.summary["passed"] - 1, 0)
+ return report
# Use shared progress utilities for consistency (aliased to maintain existing function names)
diff --git a/src/specfact_cli/modules/sync/src/commands.py b/src/specfact_cli/modules/sync/src/commands.py
index 24592520..6b8c5b5e 100644
--- a/src/specfact_cli/modules/sync/src/commands.py
+++ b/src/specfact_cli/modules/sync/src/commands.py
@@ -21,8 +21,11 @@
from specfact_cli import runtime
from specfact_cli.adapters.registry import AdapterRegistry
+from specfact_cli.contracts.module_interface import ModuleIOContract
from specfact_cli.models.bridge import AdapterType
-from specfact_cli.models.plan import Feature, PlanBundle
+from specfact_cli.models.plan import Feature, PlanBundle, Product
+from specfact_cli.models.project import BundleManifest, ProjectBundle
+from specfact_cli.models.validation import ValidationReport
from specfact_cli.runtime import debug_log_operation, debug_print, get_configured_console, is_debug_mode
from specfact_cli.telemetry import telemetry
from specfact_cli.utils.terminal import get_progress_config
@@ -32,6 +35,70 @@
help="Synchronize external tool artifacts and repository changes (Spec-Kit, OpenSpec, GitHub, Linear, Jira, etc.). See 'specfact backlog refine' for template-driven backlog refinement."
)
console = get_configured_console()
+_MODULE_IO_CONTRACT = ModuleIOContract
+
+
+@beartype
+@require(lambda source: source.exists(), "Source path must exist")
+@ensure(lambda result: isinstance(result, ProjectBundle), "Must return ProjectBundle")
+def import_to_bundle(source: Path, config: dict[str, Any]) -> ProjectBundle:
+ """Convert external source artifacts into a ProjectBundle."""
+ if source.is_dir() and (source / "bundle.manifest.yaml").exists():
+ return ProjectBundle.load_from_directory(source)
+ bundle_name = config.get("bundle_name", source.stem if source.suffix else source.name)
+ return ProjectBundle(
+ manifest=BundleManifest(schema_metadata=None, project_metadata=None),
+ bundle_name=str(bundle_name),
+ product=Product(),
+ )
+
+
+@beartype
+@require(lambda target: target is not None, "Target path must be provided")
+@ensure(lambda target: target.exists(), "Target must exist after export")
+def export_from_bundle(bundle: ProjectBundle, target: Path, config: dict[str, Any]) -> None:
+ """Export a ProjectBundle to target path."""
+ if target.suffix:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(bundle.model_dump_json(indent=2), encoding="utf-8")
+ return
+ target.mkdir(parents=True, exist_ok=True)
+ bundle.save_to_directory(target)
+
+
+@beartype
+@require(lambda external_source: len(external_source.strip()) > 0, "External source must be non-empty")
+@ensure(lambda result: isinstance(result, ProjectBundle), "Must return ProjectBundle")
+def sync_with_bundle(bundle: ProjectBundle, external_source: str, config: dict[str, Any]) -> ProjectBundle:
+ """Synchronize an existing bundle with an external source."""
+ source_path = Path(external_source)
+ if source_path.exists() and source_path.is_dir() and (source_path / "bundle.manifest.yaml").exists():
+ return ProjectBundle.load_from_directory(source_path)
+ return bundle
+
+
+@beartype
+@ensure(lambda result: isinstance(result, ValidationReport), "Must return ValidationReport")
+def validate_bundle(bundle: ProjectBundle, rules: dict[str, Any]) -> ValidationReport:
+ """Validate bundle for module-specific constraints."""
+ total_checks = max(len(rules), 1)
+ report = ValidationReport(
+ status="passed",
+ violations=[],
+ summary={"total_checks": total_checks, "passed": total_checks, "failed": 0, "warnings": 0},
+ )
+ if not bundle.bundle_name:
+ report.status = "failed"
+ report.violations.append(
+ {
+ "severity": "error",
+ "message": "Bundle name is required",
+ "location": "ProjectBundle.bundle_name",
+ }
+ )
+ report.summary["failed"] += 1
+ report.summary["passed"] = max(report.summary["passed"] - 1, 0)
+ return report
@beartype
diff --git a/src/specfact_cli/registry/module_packages.py b/src/specfact_cli/registry/module_packages.py
index 237c2b87..2fc87bea 100644
--- a/src/specfact_cli/registry/module_packages.py
+++ b/src/specfact_cli/registry/module_packages.py
@@ -12,36 +12,18 @@
from typing import Any
from beartype import beartype
+from icontract import ensure, require
from packaging.specifiers import SpecifierSet
from packaging.version import InvalidVersion, Version
-from pydantic import BaseModel, Field
from specfact_cli import __version__ as cli_version
from specfact_cli.common import get_bridge_logger
+from specfact_cli.models.module_package import ModulePackageMetadata
from specfact_cli.registry.metadata import CommandMetadata
from specfact_cli.registry.module_state import find_dependents, read_modules_state
from specfact_cli.registry.registry import CommandRegistry
-class ModulePackageMetadata(BaseModel):
- """Schema for a module package's module-package.yaml."""
-
- name: str = Field(..., description="Package identifier (e.g. backlog_refine)")
- version: str = Field(default="0.1.0", description="Package version")
- commands: list[str] = Field(default_factory=list, description="Command names this package provides")
- command_help: dict[str, str] | None = Field(
- default=None, description="Optional command name -> help text for root help"
- )
- pip_dependencies: list[str] = Field(default_factory=list, description="Optional pip dependencies")
- module_dependencies: list[str] = Field(default_factory=list, description="Optional other package ids")
- core_compatibility: str | None = Field(
- default=None,
- description="CLI core version compatibility (PEP 440 specifier, e.g. '>=0.28.0,<1.0.0')",
- )
- tier: str = Field(default="community", description="Tier: community or enterprise")
- addon_id: str | None = Field(default=None, description="Optional addon identifier")
-
-
# Display order for core modules (formerly built-in); others follow alphabetically.
CORE_MODULE_ORDER: tuple[str, ...] = (
"init",
@@ -63,6 +45,13 @@ class ModulePackageMetadata(BaseModel):
"validate",
"upgrade",
)
+CURRENT_PROJECT_SCHEMA_VERSION = "1"
+PROTOCOL_METHODS: dict[str, str] = {
+ "import": "import_to_bundle",
+ "export": "export_from_bundle",
+ "sync": "sync_with_bundle",
+ "validate": "validate_bundle",
+}
def get_modules_root() -> Path:
@@ -124,6 +113,7 @@ def discover_package_metadata(modules_root: Path) -> list[tuple[Path, ModulePack
core_compatibility=str(raw["core_compatibility"]) if raw.get("core_compatibility") else None,
tier=str(raw.get("tier", "community")),
addon_id=str(raw["addon_id"]) if raw.get("addon_id") else None,
+ schema_version=str(raw["schema_version"]) if raw.get("schema_version") is not None else None,
)
result.append((child, meta))
except Exception:
@@ -298,6 +288,57 @@ def loader() -> Any:
return loader
+def _resolve_package_load_path(package_dir: Path, package_name: str) -> Path:
+ """Resolve a package entrypoint module path."""
+ src_dir = package_dir / "src"
+ if not src_dir.exists():
+ raise ValueError(f"Package {package_dir.name} has no src/")
+ if (src_dir / "app.py").exists():
+ return src_dir / "app.py"
+ if (src_dir / f"{package_name}.py").exists():
+ return src_dir / f"{package_name}.py"
+ if (src_dir / package_name / "__init__.py").exists():
+ return src_dir / package_name / "__init__.py"
+ raise ValueError(f"Package {package_dir.name} has no src/app.py, src/{package_name}.py or src/{package_name}/")
+
+
+def _load_package_module(package_dir: Path, package_name: str) -> Any:
+ """Load and return a module package entrypoint module."""
+ load_path = _resolve_package_load_path(package_dir, package_name)
+ submodule_locations = [str(load_path.parent)] if load_path.name == "__init__.py" else None
+ spec = importlib.util.spec_from_file_location(
+ f"specfact_cli.modules.{package_dir.name}.app",
+ load_path,
+ submodule_search_locations=submodule_locations,
+ )
+ if spec is None or spec.loader is None:
+ raise ValueError(f"Cannot load from {package_dir.name}")
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+@beartype
+@require(lambda module_class: module_class is not None, "Module class must be provided")
+@ensure(lambda result: isinstance(result, list), "Protocol operation list must be returned")
+def _check_protocol_compliance(module_class: Any) -> list[str]:
+ """Return supported protocol operations based on available attributes."""
+ operations: list[str] = []
+ for operation, method_name in PROTOCOL_METHODS.items():
+ if hasattr(module_class, method_name):
+ operations.append(operation)
+ return operations
+
+
+@beartype
+@ensure(lambda result: isinstance(result, bool), "Schema compatibility check must return bool")
+def _check_schema_compatibility(module_schema: str | None, current: str) -> bool:
+ """Return True when module schema is compatible with current ProjectBundle schema."""
+ if module_schema is None:
+ return True
+ return module_schema.strip() == current.strip()
+
+
def merge_module_state(
discovered: list[tuple[str, str]],
state: dict[str, dict[str, Any]],
@@ -342,6 +383,9 @@ def register_module_package_commands(
enabled_map = merge_module_state(discovered_list, state, enable_ids, disable_ids)
logger = get_bridge_logger(__name__)
skipped: list[tuple[str, str]] = []
+ protocol_full = 0
+ protocol_partial = 0
+ protocol_legacy = 0
for package_dir, meta in packages:
if not enabled_map.get(meta.name, True):
continue
@@ -353,11 +397,60 @@ def register_module_package_commands(
if not deps_ok:
skipped.append((meta.name, f"missing dependencies: {', '.join(missing)}"))
continue
+ if not _check_schema_compatibility(meta.schema_version, CURRENT_PROJECT_SCHEMA_VERSION):
+ skipped.append(
+ (
+ meta.name,
+ f"schema version {meta.schema_version} required, current is {CURRENT_PROJECT_SCHEMA_VERSION}",
+ )
+ )
+ logger.warning(
+ "Module %s: Schema version %s required, but current is %s (skipped)",
+ meta.name,
+ meta.schema_version,
+ CURRENT_PROJECT_SCHEMA_VERSION,
+ )
+ continue
+ if meta.schema_version is None:
+ logger.debug("Module %s: No schema version declared (assuming current)", meta.name)
+ else:
+ logger.info("Module %s: Schema version %s (compatible)", meta.name, meta.schema_version)
+
+ try:
+ module_obj = _load_package_module(package_dir, meta.name)
+ operations = _check_protocol_compliance(module_obj) # type: ignore[arg-type]
+ meta.protocol_operations = operations
+ if len(operations) == 4:
+ logger.info("Module %s: ModuleIOContract fully implemented", meta.name)
+ protocol_full += 1
+ elif operations:
+ logger.info("Module %s: ModuleIOContract partial (%s)", meta.name, ", ".join(operations))
+ protocol_partial += 1
+ else:
+ logger.warning("Module %s: No ModuleIOContract (legacy mode)", meta.name)
+ protocol_legacy += 1
+ except Exception as exc:
+ logger.warning("Module %s: Unable to inspect protocol compliance (%s)", meta.name, exc)
+ meta.protocol_operations = []
+ protocol_legacy += 1
+
for cmd_name in meta.commands:
help_str = (meta.command_help or {}).get(cmd_name) or f"Module package: {meta.name}"
loader = _make_package_loader(package_dir, meta.name, cmd_name)
cmd_meta = CommandMetadata(name=cmd_name, help=help_str, tier=meta.tier, addon_id=meta.addon_id)
CommandRegistry.register(cmd_name, loader, cmd_meta)
+ discovered_count = protocol_full + protocol_partial + protocol_legacy
+ if discovered_count:
+ logger.info(
+ "Protocol-compliant: %s/%s modules (Full=%s, Partial=%s, Legacy=%s)",
+ protocol_full + protocol_partial,
+ discovered_count,
+ protocol_full,
+ protocol_partial,
+ protocol_legacy,
+ )
+ if protocol_legacy:
+ logger.warning("%s module(s) in legacy mode (no ModuleIOContract)", protocol_legacy)
for module_id, reason in skipped:
logger.debug("Skipped module '%s': %s", module_id, reason)
diff --git a/tests/unit/backlog/test_field_mappers.py b/tests/unit/backlog/test_field_mappers.py
index 1cc5b5d1..311dba73 100644
--- a/tests/unit/backlog/test_field_mappers.py
+++ b/tests/unit/backlog/test_field_mappers.py
@@ -476,7 +476,8 @@ def test_custom_mapping_overrides_defaults(self, tmp_path: Path) -> None:
def test_fallback_to_defaults_when_custom_not_found(self) -> None:
"""Test that mapper falls back to defaults when custom mapping file not found."""
- mapper = AdoFieldMapper(custom_mapping_file=Path("/nonexistent/file.yaml"))
+ with pytest.warns(UserWarning, match="Failed to load custom field mapping"):
+ mapper = AdoFieldMapper(custom_mapping_file=Path("/nonexistent/file.yaml"))
# Should still work with defaults (warns but continues)
item_data = {
diff --git a/tests/unit/commands/test_backlog_daily.py b/tests/unit/commands/test_backlog_daily.py
index bdfe8816..fcd35914 100644
--- a/tests/unit/commands/test_backlog_daily.py
+++ b/tests/unit/commands/test_backlog_daily.py
@@ -25,6 +25,8 @@
from pathlib import Path
from unittest.mock import MagicMock
+import click
+import typer.main
from typer.testing import CliRunner
from specfact_cli.backlog.adapters.base import BacklogAdapter
@@ -51,6 +53,23 @@ def _strip_ansi(text: str) -> str:
return ansi_escape.sub("", text)
+def _get_daily_command_option_names() -> set[str]:
+ """Return all option names registered on `specfact backlog daily`."""
+ root_cmd = typer.main.get_command(app)
+ root_ctx = click.Context(root_cmd)
+ backlog_cmd = root_cmd.get_command(root_ctx, "backlog")
+ assert backlog_cmd is not None
+ backlog_ctx = click.Context(backlog_cmd)
+ daily_cmd = backlog_cmd.get_command(backlog_ctx, "daily")
+ assert daily_cmd is not None
+ option_names: set[str] = set()
+ for param in daily_cmd.params:
+ if isinstance(param, click.Option):
+ option_names.update(param.opts)
+ option_names.update(param.secondary_opts)
+ return option_names
+
+
def _item(
id_: str = "1",
title: str = "Item",
@@ -186,30 +205,27 @@ class TestBacklogDailyCli:
"""CLI: specfact backlog daily."""
def test_daily_help(self) -> None:
- """Backlog daily subcommand exists and shows help."""
- result = runner.invoke(app, ["backlog", "daily", "--help"])
- assert result.exit_code == 0
- assert "daily" in result.output.lower()
+ """Backlog daily subcommand exists."""
+ option_names = _get_daily_command_option_names()
+ assert len(option_names) > 0
def test_daily_accepts_sprint_and_iteration_options(self) -> None:
"""Backlog daily has --sprint and --iteration options."""
- result = runner.invoke(app, ["backlog", "daily", "--help"])
- assert result.exit_code == 0
- # Help may include ANSI codes (e.g. on CI); check option names as substrings
- assert "sprint" in result.output.lower()
- assert "iteration" in result.output.lower()
+ option_names = _get_daily_command_option_names()
+ assert "--sprint" in option_names
+ assert "--iteration" in option_names
def test_daily_accepts_show_unassigned_and_unassigned_only(self) -> None:
"""Backlog daily has --show-unassigned and --unassigned-only options."""
- result = runner.invoke(app, ["backlog", "daily", "--help"])
- assert result.exit_code == 0
- assert "unassigned" in result.output.lower()
+ option_names = _get_daily_command_option_names()
+ assert "--show-unassigned" in option_names
+ assert "--no-show-unassigned" in option_names
+ assert "--unassigned-only" in option_names
def test_daily_accepts_blockers_first(self) -> None:
"""Backlog daily has --blockers-first option."""
- result = runner.invoke(app, ["backlog", "daily", "--help"])
- assert result.exit_code == 0
- assert "blockers-first" in result.output or "blockers" in result.output.lower()
+ option_names = _get_daily_command_option_names()
+ assert "--blockers-first" in option_names
class TestDefaultStandupScope:
@@ -441,28 +457,25 @@ class TestBacklogDailyInteractiveAndExportOptions:
def test_daily_help_shows_interactive(self) -> None:
"""Backlog daily has --interactive option."""
- result = runner.invoke(app, ["backlog", "daily", "--help-advanced"])
- assert result.exit_code == 0
- assert "--interactive" in result.output or "interactive" in result.output.lower()
+ option_names = _get_daily_command_option_names()
+ assert "--interactive" in option_names
def test_daily_help_shows_copilot_export(self) -> None:
"""Backlog daily has --copilot-export option."""
- result = runner.invoke(app, ["backlog", "daily", "--help-advanced"])
- assert result.exit_code == 0
- assert "copilot-export" in result.output or "copilot" in result.output.lower()
+ option_names = _get_daily_command_option_names()
+ assert "--copilot-export" in option_names
def test_daily_help_shows_summarize(self) -> None:
"""Backlog daily has --summarize and --summarize-to options."""
- result = runner.invoke(app, ["backlog", "daily", "--help-advanced"])
- assert result.exit_code == 0
- assert "summarize" in result.output.lower()
+ option_names = _get_daily_command_option_names()
+ assert "--summarize" in option_names
+ assert "--summarize-to" in option_names
def test_daily_help_shows_comment_annotations(self) -> None:
"""Backlog daily has --comments/--annotations option for exports."""
- result = runner.invoke(app, ["backlog", "daily", "--help-advanced"])
- assert result.exit_code == 0
- output = _strip_ansi(result.output)
- assert "--comments" in output or "--annotations" in output
+ option_names = _get_daily_command_option_names()
+ assert "--comments" in option_names
+ assert "--annotations" in option_names
class TestBuildSummarizePromptContent:
diff --git a/tests/unit/contracts/test_module_io_contract.py b/tests/unit/contracts/test_module_io_contract.py
new file mode 100644
index 00000000..66e09393
--- /dev/null
+++ b/tests/unit/contracts/test_module_io_contract.py
@@ -0,0 +1,78 @@
+"""Tests for ModuleIOContract protocol and ValidationReport model."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, cast
+
+import pytest
+from pydantic import ValidationError
+
+from specfact_cli.contracts.module_interface import ModuleIOContract
+from specfact_cli.models.project import ProjectBundle
+from specfact_cli.models.validation import ValidationReport
+
+
+class FullProtocolModule:
+ """Implements all required protocol methods without explicit inheritance."""
+
+ def import_to_bundle(self, source: Path, config: dict[str, Any]) -> ProjectBundle:
+ raise NotImplementedError
+
+ def export_from_bundle(self, bundle: ProjectBundle, target: Path, config: dict[str, Any]) -> None:
+ raise NotImplementedError
+
+ def sync_with_bundle(self, bundle: ProjectBundle, external_source: str, config: dict[str, Any]) -> ProjectBundle:
+ raise NotImplementedError
+
+ def validate_bundle(self, bundle: ProjectBundle, rules: dict[str, Any]) -> ValidationReport:
+ return ValidationReport(status="passed")
+
+
+class PartialProtocolModule:
+ """Implements only a subset of protocol methods."""
+
+ def import_to_bundle(self, source: Path, config: dict[str, Any]) -> ProjectBundle:
+ raise NotImplementedError
+
+ def validate_bundle(self, bundle: ProjectBundle, rules: dict[str, Any]) -> ValidationReport:
+ return ValidationReport(status="warnings")
+
+
+def test_protocol_defines_four_operations() -> None:
+ """Verify protocol exposes the four required operations."""
+ required = {
+ "import_to_bundle",
+ "export_from_bundle",
+ "sync_with_bundle",
+ "validate_bundle",
+ }
+ assert required.issubset(set(ModuleIOContract.__dict__.keys()))
+
+
+def test_module_without_inheritance_satisfies_protocol() -> None:
+ """Structural typing: full implementation satisfies protocol without inheritance."""
+ module = FullProtocolModule()
+ protocol_typed: ModuleIOContract = module
+ assert protocol_typed is module
+
+
+def test_module_with_partial_implementation_type_checked() -> None:
+ """Partial implementation does not satisfy full runtime contract shape."""
+ module = PartialProtocolModule()
+ assert hasattr(module, "import_to_bundle")
+ assert hasattr(module, "validate_bundle")
+ assert not hasattr(module, "export_from_bundle")
+ assert not hasattr(module, "sync_with_bundle")
+
+
+def test_validation_report_model_structure() -> None:
+ """ValidationReport provides status, violations, and summary fields."""
+ report = ValidationReport(status="failed")
+ assert report.status == "failed"
+ assert isinstance(report.violations, list)
+ assert isinstance(report.summary, dict)
+ assert set(report.summary.keys()) == {"total_checks", "passed", "failed", "warnings"}
+
+ with pytest.raises(ValidationError):
+ ValidationReport(status=cast(Any, "invalid"))
diff --git a/tests/unit/models/test_module_package_metadata.py b/tests/unit/models/test_module_package_metadata.py
new file mode 100644
index 00000000..4bdac335
--- /dev/null
+++ b/tests/unit/models/test_module_package_metadata.py
@@ -0,0 +1,30 @@
+"""Tests for module package metadata extensions."""
+
+from __future__ import annotations
+
+from specfact_cli.models.module_package import ModulePackageMetadata
+
+
+def test_metadata_includes_schema_version() -> None:
+ """Metadata model should provide optional schema_version field."""
+ metadata = ModulePackageMetadata(name="backlog", commands=["backlog"])
+ assert hasattr(metadata, "schema_version")
+
+
+def test_metadata_includes_protocol_operations() -> None:
+ """Metadata model should provide protocol_operations list field."""
+ metadata = ModulePackageMetadata(name="backlog", commands=["backlog"])
+ assert hasattr(metadata, "protocol_operations")
+ assert isinstance(metadata.protocol_operations, list)
+
+
+def test_metadata_schema_version_defaults_to_none() -> None:
+ """schema_version should default to None when omitted."""
+ metadata = ModulePackageMetadata(name="backlog", commands=["backlog"])
+ assert metadata.schema_version is None
+
+
+def test_protocol_operations_defaults_to_empty() -> None:
+ """protocol_operations should default to an empty list."""
+ metadata = ModulePackageMetadata(name="backlog", commands=["backlog"])
+ assert metadata.protocol_operations == []
diff --git a/tests/unit/models/test_project_bundle_schema.py b/tests/unit/models/test_project_bundle_schema.py
new file mode 100644
index 00000000..220645e9
--- /dev/null
+++ b/tests/unit/models/test_project_bundle_schema.py
@@ -0,0 +1,43 @@
+"""Tests for ProjectBundle schema_version field."""
+
+from __future__ import annotations
+
+from typing import Any, cast
+
+import pytest
+from pydantic import ValidationError
+
+from specfact_cli.models.plan import Product
+from specfact_cli.models.project import BundleManifest, ProjectBundle
+
+
+def test_project_bundle_has_schema_version() -> None:
+ """ProjectBundle should expose schema_version with default value '1'."""
+ bundle = ProjectBundle(
+ manifest=BundleManifest(schema_metadata=None, project_metadata=None),
+ bundle_name="test-bundle",
+ product=Product(),
+ )
+ assert bundle.schema_version == "1"
+
+
+def test_schema_version_can_be_set() -> None:
+ """ProjectBundle should accept custom schema_version values."""
+ bundle = ProjectBundle(
+ manifest=BundleManifest(schema_metadata=None, project_metadata=None),
+ bundle_name="test-bundle",
+ product=Product(),
+ schema_version="2",
+ )
+ assert bundle.schema_version == "2"
+
+
+def test_schema_version_validation() -> None:
+ """ProjectBundle schema_version must be validated as string."""
+ with pytest.raises(ValidationError):
+ ProjectBundle(
+ manifest=BundleManifest(schema_metadata=None, project_metadata=None),
+ bundle_name="test-bundle",
+ product=Product(),
+ schema_version=cast(Any, 1),
+ )
diff --git a/tests/unit/modules/backlog/test_module_io_contract.py b/tests/unit/modules/backlog/test_module_io_contract.py
new file mode 100644
index 00000000..0cda74a9
--- /dev/null
+++ b/tests/unit/modules/backlog/test_module_io_contract.py
@@ -0,0 +1,38 @@
+"""Module IO contract tests for backlog module."""
+
+from __future__ import annotations
+
+import inspect
+
+from specfact_cli.modules.backlog.src import commands as module_commands
+
+
+REQUIRED_METHODS = [
+ "import_to_bundle",
+ "export_from_bundle",
+ "sync_with_bundle",
+ "validate_bundle",
+]
+
+
+def test_module_implements_protocol() -> None:
+ for method_name in REQUIRED_METHODS:
+ assert hasattr(module_commands, method_name)
+
+
+def test_import_to_bundle_signature() -> None:
+ signature = inspect.signature(module_commands.import_to_bundle)
+ assert set(signature.parameters.keys()) == {"source", "config"}
+
+
+def test_export_from_bundle_signature() -> None:
+ signature = inspect.signature(module_commands.export_from_bundle)
+ assert set(signature.parameters.keys()) == {"bundle", "target", "config"}
+
+
+def test_methods_have_contracts() -> None:
+ for method_name in REQUIRED_METHODS:
+ method = getattr(module_commands, method_name)
+ assert hasattr(method, "__wrapped__")
+ assert hasattr(method, "__preconditions__")
+ assert hasattr(method, "__postconditions__")
diff --git a/tests/unit/modules/enforce/test_module_io_contract.py b/tests/unit/modules/enforce/test_module_io_contract.py
new file mode 100644
index 00000000..f739bcc2
--- /dev/null
+++ b/tests/unit/modules/enforce/test_module_io_contract.py
@@ -0,0 +1,38 @@
+"""Module IO contract tests for enforce module."""
+
+from __future__ import annotations
+
+import inspect
+
+from specfact_cli.modules.enforce.src import commands as module_commands
+
+
+REQUIRED_METHODS = [
+ "import_to_bundle",
+ "export_from_bundle",
+ "sync_with_bundle",
+ "validate_bundle",
+]
+
+
+def test_module_implements_protocol() -> None:
+ for method_name in REQUIRED_METHODS:
+ assert hasattr(module_commands, method_name)
+
+
+def test_import_to_bundle_signature() -> None:
+ signature = inspect.signature(module_commands.import_to_bundle)
+ assert set(signature.parameters.keys()) == {"source", "config"}
+
+
+def test_export_from_bundle_signature() -> None:
+ signature = inspect.signature(module_commands.export_from_bundle)
+ assert set(signature.parameters.keys()) == {"bundle", "target", "config"}
+
+
+def test_methods_have_contracts() -> None:
+ for method_name in REQUIRED_METHODS:
+ method = getattr(module_commands, method_name)
+ assert hasattr(method, "__wrapped__")
+ assert hasattr(method, "__preconditions__")
+ assert hasattr(method, "__postconditions__")
diff --git a/tests/unit/modules/generate/test_module_io_contract.py b/tests/unit/modules/generate/test_module_io_contract.py
new file mode 100644
index 00000000..8d0bcce8
--- /dev/null
+++ b/tests/unit/modules/generate/test_module_io_contract.py
@@ -0,0 +1,38 @@
+"""Module IO contract tests for generate module."""
+
+from __future__ import annotations
+
+import inspect
+
+from specfact_cli.modules.generate.src import commands as module_commands
+
+
+REQUIRED_METHODS = [
+ "import_to_bundle",
+ "export_from_bundle",
+ "sync_with_bundle",
+ "validate_bundle",
+]
+
+
+def test_module_implements_protocol() -> None:
+ for method_name in REQUIRED_METHODS:
+ assert hasattr(module_commands, method_name)
+
+
+def test_import_to_bundle_signature() -> None:
+ signature = inspect.signature(module_commands.import_to_bundle)
+ assert set(signature.parameters.keys()) == {"source", "config"}
+
+
+def test_export_from_bundle_signature() -> None:
+ signature = inspect.signature(module_commands.export_from_bundle)
+ assert set(signature.parameters.keys()) == {"bundle", "target", "config"}
+
+
+def test_methods_have_contracts() -> None:
+ for method_name in REQUIRED_METHODS:
+ method = getattr(module_commands, method_name)
+ assert hasattr(method, "__wrapped__")
+ assert hasattr(method, "__preconditions__")
+ assert hasattr(method, "__postconditions__")
diff --git a/tests/unit/modules/plan/test_module_io_contract.py b/tests/unit/modules/plan/test_module_io_contract.py
new file mode 100644
index 00000000..83489ec2
--- /dev/null
+++ b/tests/unit/modules/plan/test_module_io_contract.py
@@ -0,0 +1,38 @@
+"""Module IO contract tests for plan module."""
+
+from __future__ import annotations
+
+import inspect
+
+from specfact_cli.modules.plan.src import commands as module_commands
+
+
+REQUIRED_METHODS = [
+ "import_to_bundle",
+ "export_from_bundle",
+ "sync_with_bundle",
+ "validate_bundle",
+]
+
+
+def test_module_implements_protocol() -> None:
+ for method_name in REQUIRED_METHODS:
+ assert hasattr(module_commands, method_name)
+
+
+def test_import_to_bundle_signature() -> None:
+ signature = inspect.signature(module_commands.import_to_bundle)
+ assert set(signature.parameters.keys()) == {"source", "config"}
+
+
+def test_export_from_bundle_signature() -> None:
+ signature = inspect.signature(module_commands.export_from_bundle)
+ assert set(signature.parameters.keys()) == {"bundle", "target", "config"}
+
+
+def test_methods_have_contracts() -> None:
+ for method_name in REQUIRED_METHODS:
+ method = getattr(module_commands, method_name)
+ assert hasattr(method, "__wrapped__")
+ assert hasattr(method, "__preconditions__")
+ assert hasattr(method, "__postconditions__")
diff --git a/tests/unit/modules/sync/test_module_io_contract.py b/tests/unit/modules/sync/test_module_io_contract.py
new file mode 100644
index 00000000..a1d93bce
--- /dev/null
+++ b/tests/unit/modules/sync/test_module_io_contract.py
@@ -0,0 +1,38 @@
+"""Module IO contract tests for sync module."""
+
+from __future__ import annotations
+
+import inspect
+
+from specfact_cli.modules.sync.src import commands as module_commands
+
+
+REQUIRED_METHODS = [
+ "import_to_bundle",
+ "export_from_bundle",
+ "sync_with_bundle",
+ "validate_bundle",
+]
+
+
+def test_module_implements_protocol() -> None:
+ for method_name in REQUIRED_METHODS:
+ assert hasattr(module_commands, method_name)
+
+
+def test_import_to_bundle_signature() -> None:
+ signature = inspect.signature(module_commands.import_to_bundle)
+ assert set(signature.parameters.keys()) == {"source", "config"}
+
+
+def test_export_from_bundle_signature() -> None:
+ signature = inspect.signature(module_commands.export_from_bundle)
+ assert set(signature.parameters.keys()) == {"bundle", "target", "config"}
+
+
+def test_methods_have_contracts() -> None:
+ for method_name in REQUIRED_METHODS:
+ method = getattr(module_commands, method_name)
+ assert hasattr(method, "__wrapped__")
+ assert hasattr(method, "__preconditions__")
+ assert hasattr(method, "__postconditions__")
diff --git a/tests/unit/registry/test_module_protocol_validation.py b/tests/unit/registry/test_module_protocol_validation.py
new file mode 100644
index 00000000..b1d5b603
--- /dev/null
+++ b/tests/unit/registry/test_module_protocol_validation.py
@@ -0,0 +1,58 @@
+"""Tests for module protocol validation during discovery/registration."""
+
+from __future__ import annotations
+
+from specfact_cli.registry.module_packages import _check_protocol_compliance, _check_schema_compatibility
+
+
+class FullProtocolModule:
+ def import_to_bundle(self):
+ return None
+
+ def export_from_bundle(self):
+ return None
+
+ def sync_with_bundle(self):
+ return None
+
+ def validate_bundle(self):
+ return None
+
+
+class PartialProtocolModule:
+ def import_to_bundle(self):
+ return None
+
+ def validate_bundle(self):
+ return None
+
+
+class LegacyModule:
+ def run(self):
+ return None
+
+
+def test_discovery_detects_protocol_implementation() -> None:
+ operations = _check_protocol_compliance(FullProtocolModule)
+ assert set(operations) == {"import", "export", "sync", "validate"}
+
+
+def test_full_protocol_logged() -> None:
+ operations = _check_protocol_compliance(FullProtocolModule)
+ assert len(operations) == 4
+
+
+def test_partial_protocol_logged() -> None:
+ operations = _check_protocol_compliance(PartialProtocolModule)
+ assert set(operations) == {"import", "validate"}
+
+
+def test_no_protocol_legacy_mode() -> None:
+ operations = _check_protocol_compliance(LegacyModule)
+ assert operations == []
+
+
+def test_schema_version_compatibility_check() -> None:
+ assert _check_schema_compatibility("1", "1") is True
+ assert _check_schema_compatibility(None, "1") is True
+ assert _check_schema_compatibility("2", "1") is False
diff --git a/tests/unit/test_core_module_isolation.py b/tests/unit/test_core_module_isolation.py
new file mode 100644
index 00000000..f4cd199a
--- /dev/null
+++ b/tests/unit/test_core_module_isolation.py
@@ -0,0 +1,139 @@
+"""Core-module isolation tests."""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+CORE_DIRS = [
+ Path("src/specfact_cli/cli.py"),
+ Path("src/specfact_cli/registry"),
+ Path("src/specfact_cli/models"),
+ Path("src/specfact_cli/utils"),
+ Path("src/specfact_cli/contracts"),
+]
+
+
+def _collect_python_files(dirs: list[Path]) -> list[Path]:
+ """Collect Python files from a list of file and directory paths."""
+ files: list[Path] = []
+ for relative in dirs:
+ target = REPO_ROOT / relative
+ if target.is_file() and target.suffix == ".py":
+ files.append(target)
+ continue
+ if target.is_dir():
+ files.extend(sorted(target.rglob("*.py")))
+ return files
+
+
+def _get_module_name(node: ast.AST) -> str:
+ """Extract the imported module name from Import/ImportFrom nodes."""
+ if isinstance(node, ast.Import):
+ if node.names:
+ return node.names[0].name
+ return ""
+ if isinstance(node, ast.ImportFrom):
+ return node.module or ""
+ return ""
+
+
+def _is_type_checking_test(node: ast.AST) -> bool:
+ """Return True when an AST expression node checks TYPE_CHECKING."""
+ if isinstance(node, ast.Name):
+ return node.id == "TYPE_CHECKING"
+ if isinstance(node, ast.Attribute):
+ return node.attr == "TYPE_CHECKING"
+ return False
+
+
+def _is_in_type_checking_block(node: ast.AST, parent_map: dict[ast.AST, ast.AST]) -> bool:
+ """Determine whether a node is nested under an `if TYPE_CHECKING:` block."""
+ current: ast.AST | None = node
+ while current is not None:
+ parent = parent_map.get(current)
+ if parent is None:
+ return False
+ if isinstance(parent, ast.If) and _is_type_checking_test(parent.test):
+ return True
+ current = parent
+ return False
+ return False
+
+
+def _format_violation(path: str, line_no: int, module: str) -> str:
+ return f"{path}:{line_no} imports {module}"
+
+
+def _find_core_module_import_violations(files: list[Path]) -> list[str]:
+ """Scan Python files and return all direct core->module import violations."""
+ violations: list[str] = []
+ for file_path in files:
+ source = file_path.read_text(encoding="utf-8")
+ tree = ast.parse(source, filename=str(file_path))
+ parent_map = {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)}
+
+ for node in ast.walk(tree):
+ if not isinstance(node, (ast.Import, ast.ImportFrom)):
+ continue
+ if _is_in_type_checking_block(node, parent_map):
+ continue
+ module_name = _get_module_name(node)
+ if not module_name.startswith("specfact_cli.modules."):
+ continue
+ violations.append(
+ _format_violation(
+ str(file_path.relative_to(REPO_ROOT)),
+ getattr(node, "lineno", 0),
+ module_name,
+ )
+ )
+ return violations
+
+
+def test_core_has_no_module_imports() -> None:
+ """Core directories should not import module package code directly."""
+ core_files = _collect_python_files(CORE_DIRS)
+ violations = _find_core_module_import_violations(core_files)
+
+ assert not violations, "\n".join([f"Found {len(violations)} core-to-module import violations", *violations])
+
+
+def test_excludes_type_checking_blocks() -> None:
+ """Imports in TYPE_CHECKING blocks are allowed by isolation policy."""
+ source = ast.parse(
+ """
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from specfact_cli.modules.backlog.src import commands
+"""
+ )
+ parent_map = {child: parent for parent in ast.walk(source) for child in ast.iter_child_nodes(parent)}
+ imports = [node for node in ast.walk(source) if isinstance(node, (ast.Import, ast.ImportFrom))]
+ module_imports = [node for node in imports if _get_module_name(node).startswith("specfact_cli.modules.")]
+
+ assert module_imports
+ assert all(_is_in_type_checking_block(node, parent_map) for node in module_imports)
+
+
+def test_multiple_violations_reported_together() -> None:
+ """Violation reporting aggregates all issues in a single error payload."""
+ violations = [
+ _format_violation("src/specfact_cli/cli.py", 10, "specfact_cli.modules.backlog"),
+ _format_violation("src/specfact_cli/models/project.py", 42, "specfact_cli.modules.sync"),
+ ]
+ message = "\n".join([f"Found {len(violations)} core-to-module import violations", *violations])
+
+ assert "Found 2 core-to-module import violations" in message
+ assert "src/specfact_cli/cli.py:10" in message
+ assert "src/specfact_cli/models/project.py:42" in message
+
+
+def test_violation_message_format() -> None:
+ """Violation messages include file path, line number, and module name."""
+ violation = _format_violation("src/specfact_cli/cli.py", 42, "specfact_cli.modules.backlog.src.commands")
+
+ assert violation == "src/specfact_cli/cli.py:42 imports specfact_cli.modules.backlog.src.commands"