Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,29 @@ Panics are not used for recoverable errors. `unwrap()` and `expect()` are only a

-----

## Schema versioning

Every persisted file format carries a `schema_version` integer field. This enables forward-compatible rejection: if a file was written by a newer weave version with a schema the current build does not understand, weave refuses to load it with a clear "please upgrade" error rather than silently misinterpreting the data.

**Versioned formats:**

| File | Constant | Location |
|------|----------|----------|
| `pack.toml` | `CURRENT_PACK_SCHEMA_VERSION` | `core/pack.rs` |
| Profile lock files (`*.lock`) | `CURRENT_LOCKFILE_SCHEMA_VERSION` | `core/lockfile.rs` |
| Registry `index.json` | `CURRENT_REGISTRY_SCHEMA_VERSION` | `core/registry.rs` |
| Registry `packs/{name}.json` | `CURRENT_REGISTRY_SCHEMA_VERSION` | `core/registry.rs` |
| Adapter tracking files (`.packweave_manifest.json`) | `CURRENT_MANIFEST_SCHEMA_VERSION` | `adapters/mod.rs` |

**Rules:**

1. **Default is 1.** When `schema_version` is absent, serde defaults it to `1`. This provides backward compatibility with files written before versioning was added.
2. **Reject, never guess.** If `schema_version > CURRENT_*_SCHEMA_VERSION`, the load function returns `WeaveError::SchemaVersionTooNew`. No fallback parsing is attempted.
3. **Bump the constant, not the code.** When a format change ships, bump the relevant `CURRENT_*` constant. Older clients will reject the new format automatically.
4. **Registry index supports two shapes.** The `index.json` file accepts both a versioned envelope (`{"schema_version": N, "packs": {…}}`) and the legacy flat format (`{"pack-name": {…}, …}`) for backward compatibility with older registries and taps.

-----

## Testing strategy

- **Unit tests** live alongside the module they test (`#[cfg(test)]` blocks).
Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ The milestones below are sequential. Each one produces something usable before t
- [ ] Enforce `min_tool_version` check during pack install (issue #197)
- [ ] Switch Codex adapter to `toml_edit` to preserve user comments (issue #212)
- [ ] Rollback on partial adapter apply failure — don't record install if any adapter fails (issue #221)
- [ ] Schema versioning for pack.toml and sidecar manifests — graceful rejection of newer formats (issue #224)
- [x] Schema versioning for pack.toml and sidecar manifests — graceful rejection of newer formats (issue #224)

### Adoption Accelerators

Expand Down
4 changes: 4 additions & 0 deletions pack.schema.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
# This is not a functional file — it's documentation.
# Generate a real pack.toml with: weave init

# Schema version of this file format. Defaults to 1 if omitted.
# Older weave versions will reject pack.toml files with a version they don't support.
schema_version = 1

# ─────────────────────────────────────────────
# [pack] — required
# Core metadata about the pack.
Expand Down
77 changes: 73 additions & 4 deletions src/adapters/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::adapters::{ApplyOptions, CliAdapter, DiagnosticIssue, Severity};
use crate::adapters::{
ApplyOptions, CURRENT_MANIFEST_SCHEMA_VERSION, CliAdapter, DiagnosticIssue, Severity,
default_manifest_schema_version,
};
use crate::core::pack::{McpServer, ResolvedPack, Transport};
use crate::core::store::Store;
use crate::error::{Result, WeaveError};
Expand All @@ -20,8 +23,10 @@ struct SettingsRecord {
}

/// Sidecar manifest tracking what weave wrote to Claude Code config.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PackweaveManifest {
#[serde(default = "default_manifest_schema_version")]
schema_version: u32,
#[serde(default)]
servers: HashMap<String, String>, // server_name -> pack_name
#[serde(default)]
Expand All @@ -41,6 +46,20 @@ struct PackweaveManifest {
project_dirs: HashMap<String, Vec<String>>, // pack_name -> [project_root_abs_paths]
}

impl Default for PackweaveManifest {
fn default() -> Self {
Self {
schema_version: CURRENT_MANIFEST_SCHEMA_VERSION,
servers: HashMap::new(),
commands: HashMap::new(),
prompt_blocks: Vec::new(),
settings: HashMap::new(),
hooks: Vec::new(),
project_dirs: HashMap::new(),
}
}
}

pub struct ClaudeCodeAdapter {
home: Option<PathBuf>,
/// Current working directory, used as the project root for project-scope config.
Expand Down Expand Up @@ -190,7 +209,17 @@ impl ClaudeCodeAdapter {
return Ok(PackweaveManifest::default());
}
let content = util::read_file(&path)?;
serde_json::from_str(&content).map_err(|e| WeaveError::Json { path, source: e })
let manifest: PackweaveManifest =
serde_json::from_str(&content).map_err(|e| WeaveError::Json {
path: path.clone(),
source: e,
})?;
super::check_manifest_schema_version(
manifest.schema_version,
"Claude Code tracking file",
path,
)?;
Ok(manifest)
}

fn save_manifest(&self, manifest: &PackweaveManifest) -> Result<()> {
Expand All @@ -207,7 +236,17 @@ impl ClaudeCodeAdapter {
return Ok(PackweaveManifest::default());
}
let content = util::read_file(&path)?;
serde_json::from_str(&content).map_err(|e| WeaveError::Json { path, source: e })
let manifest: PackweaveManifest =
serde_json::from_str(&content).map_err(|e| WeaveError::Json {
path: path.clone(),
source: e,
})?;
super::check_manifest_schema_version(
manifest.schema_version,
"Claude Code tracking file",
path,
)?;
Ok(manifest)
}

fn save_project_manifest(&self, manifest: &PackweaveManifest) -> Result<()> {
Expand Down Expand Up @@ -2184,4 +2223,34 @@ mod tests {
"project root should be retained when CLAUDE.md has orphaned prompt blocks"
);
}

#[test]
fn reject_manifest_with_future_schema_version() {
let dir = TempDir::new().unwrap();
let home = dir.path().join("home");
std::fs::create_dir_all(home.join(".claude")).unwrap();

// Write a manifest with a future schema version.
let manifest_path = home.join(".claude/.packweave_manifest.json");
std::fs::write(
&manifest_path,
r#"{"schema_version": 99, "servers": {}, "commands": {}}"#,
)
.unwrap();

let adapter = test_adapter_with_home(&home);
let result = adapter.load_manifest();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("schema version 99"),
"expected 'schema version 99' in error: {msg}"
);
}

fn test_adapter_with_home(home: &std::path::Path) -> ClaudeCodeAdapter {
let no_project = home.join("no-project");
std::fs::create_dir_all(&no_project).unwrap();
ClaudeCodeAdapter::with_home_and_project(home.to_path_buf(), no_project)
}
}
72 changes: 68 additions & 4 deletions src/adapters/codex_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use toml_edit::DocumentMut;

use crate::adapters::{ApplyOptions, CliAdapter, DiagnosticIssue, Severity};
use crate::adapters::{
ApplyOptions, CURRENT_MANIFEST_SCHEMA_VERSION, CliAdapter, DiagnosticIssue, Severity,
default_manifest_schema_version,
};
use crate::core::pack::{McpServer, PackSource, ResolvedPack, Transport};
use crate::core::store::Store;
use crate::error::{Result, WeaveError};
Expand All @@ -22,8 +25,10 @@ struct SettingsRecord {
}

/// Sidecar manifest tracking what weave wrote to Codex CLI config.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CodexManifest {
#[serde(default = "default_manifest_schema_version")]
schema_version: u32,
#[serde(default)]
servers: HashMap<String, String>, // server_name -> pack_name
#[serde(default)]
Expand All @@ -34,6 +39,18 @@ struct CodexManifest {
skills: HashMap<String, String>, // filename -> pack_name
}

impl Default for CodexManifest {
fn default() -> Self {
Self {
schema_version: CURRENT_MANIFEST_SCHEMA_VERSION,
servers: HashMap::new(),
prompt_blocks: Vec::new(),
settings: HashMap::new(),
skills: HashMap::new(),
}
}
}

pub struct CodexAdapter {
home: Option<PathBuf>,
/// Current working directory, used to detect project-scope config.
Expand Down Expand Up @@ -124,7 +141,17 @@ impl CodexAdapter {
return Ok(CodexManifest::default());
}
let content = util::read_file(&path)?;
serde_json::from_str(&content).map_err(|e| WeaveError::Json { path, source: e })
let manifest: CodexManifest =
serde_json::from_str(&content).map_err(|e| WeaveError::Json {
path: path.clone(),
source: e,
})?;
super::check_manifest_schema_version(
manifest.schema_version,
"Codex CLI tracking file",
path,
)?;
Ok(manifest)
}

fn save_manifest(&self, manifest: &CodexManifest) -> Result<()> {
Expand All @@ -141,7 +168,17 @@ impl CodexAdapter {
return Ok(CodexManifest::default());
}
let content = util::read_file(&path)?;
serde_json::from_str(&content).map_err(|e| WeaveError::Json { path, source: e })
let manifest: CodexManifest =
serde_json::from_str(&content).map_err(|e| WeaveError::Json {
path: path.clone(),
source: e,
})?;
super::check_manifest_schema_version(
manifest.schema_version,
"Codex CLI tracking file",
path,
)?;
Ok(manifest)
}

fn save_project_manifest(&self, manifest: &CodexManifest) -> Result<()> {
Expand Down Expand Up @@ -1273,3 +1310,30 @@ fn which_exists(cmd: &str) -> bool {
.map(|s| s.success())
.unwrap_or(false)
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;

#[test]
fn reject_manifest_with_future_schema_version() {
let dir = TempDir::new().unwrap();
let home = dir.path().to_path_buf();
std::fs::create_dir_all(home.join(".codex")).unwrap();

let manifest_path = home.join(".codex/.packweave_manifest.json");
std::fs::write(&manifest_path, r#"{"schema_version": 99, "servers": {}}"#).unwrap();

let no_project = home.join("no-project");
std::fs::create_dir_all(&no_project).unwrap();
let adapter = CodexAdapter::with_home_and_project(home, no_project);
let result = adapter.load_manifest();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("schema version 99"),
"expected 'schema version 99' in error: {msg}"
);
}
}
65 changes: 61 additions & 4 deletions src/adapters/gemini_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::adapters::{ApplyOptions, CliAdapter, DiagnosticIssue, Severity};
use crate::adapters::{
ApplyOptions, CURRENT_MANIFEST_SCHEMA_VERSION, CliAdapter, DiagnosticIssue, Severity,
default_manifest_schema_version,
};
use crate::core::pack::{McpServer, ResolvedPack, Transport};
use crate::core::store::Store;
use crate::error::{Result, WeaveError};
Expand All @@ -17,8 +20,10 @@ struct SettingsRecord {
}

/// Sidecar manifest tracking what weave wrote to Gemini CLI config.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GeminiManifest {
#[serde(default = "default_manifest_schema_version")]
schema_version: u32,
#[serde(default)]
servers: HashMap<String, String>, // server_name -> pack_name
#[serde(default)]
Expand All @@ -27,6 +32,17 @@ struct GeminiManifest {
settings: HashMap<String, SettingsRecord>, // pack_name -> settings record
}

impl Default for GeminiManifest {
fn default() -> Self {
Self {
schema_version: CURRENT_MANIFEST_SCHEMA_VERSION,
servers: HashMap::new(),
prompt_blocks: Vec::new(),
settings: HashMap::new(),
}
}
}

pub struct GeminiCliAdapter {
home: Option<PathBuf>,
/// Current working directory, used to detect project-scope config.
Expand Down Expand Up @@ -107,7 +123,17 @@ impl GeminiCliAdapter {
return Ok(GeminiManifest::default());
}
let content = util::read_file(&path)?;
serde_json::from_str(&content).map_err(|e| WeaveError::Json { path, source: e })
let manifest: GeminiManifest =
serde_json::from_str(&content).map_err(|e| WeaveError::Json {
path: path.clone(),
source: e,
})?;
super::check_manifest_schema_version(
manifest.schema_version,
"Gemini CLI tracking file",
path,
)?;
Ok(manifest)
}

fn save_manifest(&self, manifest: &GeminiManifest) -> Result<()> {
Expand All @@ -124,7 +150,17 @@ impl GeminiCliAdapter {
return Ok(GeminiManifest::default());
}
let content = util::read_file(&path)?;
serde_json::from_str(&content).map_err(|e| WeaveError::Json { path, source: e })
let manifest: GeminiManifest =
serde_json::from_str(&content).map_err(|e| WeaveError::Json {
path: path.clone(),
source: e,
})?;
super::check_manifest_schema_version(
manifest.schema_version,
"Gemini CLI tracking file",
path,
)?;
Ok(manifest)
}

fn save_project_manifest(&self, manifest: &GeminiManifest) -> Result<()> {
Expand Down Expand Up @@ -1260,4 +1296,25 @@ mod tests {
"env key should not be present when server has no env vars"
);
}

#[test]
fn reject_manifest_with_future_schema_version() {
let dir = TempDir::new().unwrap();
let home = dir.path().to_path_buf();
std::fs::create_dir_all(home.join(".gemini")).unwrap();

let manifest_path = home.join(".gemini/.packweave_manifest.json");
std::fs::write(&manifest_path, r#"{"schema_version": 99, "servers": {}}"#).unwrap();

let no_project = home.join("no-project");
std::fs::create_dir_all(&no_project).unwrap();
let adapter = GeminiCliAdapter::with_home_and_project(home, no_project);
let result = adapter.load_manifest();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("schema version 99"),
"expected 'schema version 99' in error: {msg}"
);
}
}
Loading
Loading