aRustyDev / aRustyDev/mdbook-htmx

docs(adr): ADR-0015: Manifest Schema Versioning

Open
#23 0 comments 0 reactions 1 assignee Claimed by @aRustyDev View on GitHub
documentation
Dominant language
Rust
Stars
0
Forks
1
PR merge metrics
No merged PRs in 30d

Description

# ADR-0015: Manifest Schema Versioning

## Status

Accepted

## Context

mdbook-htmx generates `manifest.json` at build time, which servers consume at runtime for routing, authorization, and navigation. The manifest is a contract between the generator (mdbook-htmx) and consumers (servers). As both evolve, we need:

1. **Backward compatibility** - Old servers can read new manifests (within reason)
2. **Forward compatibility** - New servers can read old manifests
3. **Clear versioning** - Consumers know what to expect
4. **Migration support** - Tooling to upgrade manifests

## Decision Drivers

1. **Consumer Safety** - Servers must not crash on unknown fields
2. **Generator Flexibility** - Add features without breaking consumers
3. **Runtime Validation** - Servers can validate manifest on load
4. **Ecosystem Compatibility** - Multiple server implementations

## Decision

**Use semantic versioning with explicit version field and structured evolution rules.**

### Manifest Structure

```json
{
"$schema": "https://schemas.arusty.dev/mdbook-htmx/manifest/v1.0.schema.json",
"version": "1.0.0",
"generator": {
"name": "mdbook-htmx",
"version": "0.1.0"
},
"generated": "2026-01-04T12:00:00Z",
"book": {
"title": "Example Book",
"authors": ["John Doe"],
"description": "An example book",
"language": "en"
},
"authn": {
"signin": "/auth/signin",
"signout": "/auth/signout"
},
"scopes": {
"available": ["all", "developers", "managers", "sre"],
"default": "all"
},
"pages": [...],
"navigation": {...}
}
```

### Version Field Semantics

The `version` field follows semver:

| Version Change | Meaning |
|----------------|---------|
| `1.0.0` → `1.0.1` | Patch: Bug fixes, no schema changes |
| `1.0.0` → `1.1.0` | Minor: New optional fields, backward compatible |
| `1.0.0` → `2.0.0` | Major: Breaking changes, requires migration |

### Compatibility Matrix

| Consumer Version | Manifest 1.0 | Manifest 1.1 | Manifest 2.0 |
|------------------|--------------|--------------|--------------|
| Server v1.0 | Full | Partial* | Unsupported |
| Server v1.1 | Full | Full | Unsupported |
| Server v2.0 | Via migration | Via migration | Full |

*Partial: New fields ignored, core functionality works

## Schema Evolution Rules

### Patch Version (1.0.0 → 1.0.1)

- Fix schema documentation only
- No field changes
- Consumers don't need updates

### Minor Version (1.0.x → 1.1.0)

Allowed changes:
- Add new optional fields with defaults
- Add new enum values
- Expand allowed types (e.g., string → string | array)
- Add nested objects with all optional fields

```json
// v1.0.0
{
"pages": [{
"path": "/docs/intro",
"title": "Introduction"
}]
}

// v1.1.0 - adds optional preload field
{
"pages": [{
"path": "/docs/intro",
"title": "Introduction",
"htmx": {
"preload": true // New optional field
}
}]
}
```

### Major Version (1.x.x → 2.0.0)

Breaking changes allowed:
- Remove fields
- Change field types
- Rename fields
- Restructure objects
- Change required fields

```json
// v1.x.x
{
"pages": [{
"auth": { "roles": ["admin"] }
}]
}

// v2.0.0 - restructured auth
{
"pages": [{
"authorization": {
"policy": "roles",
"required_roles": ["admin"]
}
}]
}
```

## Implementation

### Generator Side

```rust
use chrono::Utc;
use serde::Serialize;

const MANIFEST_SCHEMA_VERSION: &str = "1.0.0";

#[derive(Serialize)]
pub struct Manifest {
#[serde(rename = "$schema")]
pub schema: String,
pub version: String,
pub generator: GeneratorInfo,
pub generated: String,
pub book: BookMeta,
pub authn: Option,
pub scopes: Option,
pub pages: Vec,
pub navigation: Navigation,
}

#[derive(Serialize)]
pub struct GeneratorInfo {
pub name: String,
pub version: String,
}

impl Manifest {
pub fn new(ctx: &RenderContext, pages: Vec) -> Self {
Self {
schema: format!(
"https://schemas.arusty.dev/mdbook-htmx/manifest/v{}.schema.json",
MANIFEST_SCHEMA_VERSION.rsplit('.').last().unwrap()
),
version: MANIFEST_SCHEMA_VERSION.to_string(),
generator: GeneratorInfo {
name: "mdbook-htmx".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
generated: Utc::now().to_rfc3339(),
book: BookMeta::from(ctx),
authn: None,
scopes: None,
pages,
navigation: Navigation::default(),
}
}
}
```

### Consumer Side (TypeScript)

```typescript
import Ajv from "ajv";
import manifestSchema from "./schemas/manifest.v1.schema.json";

interface Manifest {
version: string;
generator: { name: string; version: string };
generated: string;
book: BookMeta;
pages: PageMeta[];
navigation: Navigation;
}

const ajv = new Ajv({ strict: false }); // Allow unknown properties
const validate = ajv.compile(manifestSchema);

export function loadManifest(json: unknown): Manifest {
// Validate against schema
if (!validate(json)) {
throw new Error(`Invalid manifest: ${ajv.errorsText(validate.errors)}`);
}

const manifest = json as Manifest;

// Check version compatibility
const [major] = manifest.version.split(".").map(Number);
if (major !== 1) {
throw new Error(
`Unsupported manifest version: ${manifest.version}. ` +
`This server supports v1.x.x`
);
}

return manifest;
}

// Runtime access with fallbacks for newer fields
export function getPagePreload(page: PageMeta): boolean {
// htmx.preload added in v1.1.0, default to false
return page.htmx?.preload ?? false;
}
```

### Version Negotiation

Servers can request specific manifest versions:

```typescript
// Server startup
const manifest = loadManifest(await readFile("manifest.json"));

// Log compatibility info
console.log(`Manifest version: ${manifest.version}`);
console.log(`Generated by: ${manifest.generator.name} v${manifest.generator.version}`);
console.log(`Generated at: ${manifest.generated}`);

// Check for known version
const [major, minor] = manifest.version.split(".").map(Number);
if (major === 1 && minor > 1) {
console.warn(
`Manifest v${manifest.version} may contain features not supported ` +
`by this server. Consider upgrading.`
);
}
```

## Scope-Specific Manifests

Per ADR-0009, scope-filtered manifests follow the same versioning:

```
book/htmx/
├── manifest.json # Full manifest (v1.0.0)
├── manifest.developers.json # Scope-filtered (v1.0.0)
├── manifest.managers.json # Scope-filtered (v1.0.0)
└── manifest.sre.json # Scope-filtered (v1.0.0)
```

All manifests from the same build share the same version.

## Migration Tooling

### CLI Commands

```bash
# Check manifest version and compatibility
$ mdbook-htmx manifest info manifest.json
Manifest Version: 1.0.0
Schema: https://schemas.arusty.dev/mdbook-htmx/manifest/v1.schema.json
Generator: mdbook-htmx v0.1.0
Generated: 2026-01-04T12:00:00Z
Pages: 42
Compatible with: Server v1.x

# Validate manifest against schema
$ mdbook-htmx manifest validate manifest.json
Validating against schema v1.0...
Manifest is valid.

# Migrate manifest to newer version (if possible)
$ mdbook-htmx manifest migrate manifest.json --target 1.1
Migrating from v1.0.0 to v1.1.0...
- Added htmx.preload defaults
- Added scopes section
Written: manifest.json.new
```

### Programmatic Migration

```typescript
// Server-side manifest migration
export function migrateManifest(
manifest: unknown,
targetVersion: string
): Manifest {
const current = (manifest as { version: string }).version;
const [currentMajor, currentMinor] = current.split(".").map(Number);
const [targetMajor, targetMinor] = targetVersion.split(".").map(Number);

if (currentMajor !== targetMajor) {
throw new Error(
`Cannot migrate across major versions (${current} → ${targetVersion})`
);
}

let result = manifest as Manifest;

// Apply migrations in order
if (currentMinor < 1 && targetMinor >= 1) {
result = migrateV1_0ToV1_1(result);
}
// Add more migrations as needed

result.version = targetVersion;
return result;
}

function migrateV1_0ToV1_1(manifest: Manifest): Manifest {
return {
...manifest,
pages: manifest.pages.map((page) => ({
...page,
htmx: {
preload: false,
...page.htmx,
},
})),
scopes: manifest.scopes ?? {
available: ["all"],
default: "all",
},
};
}
```

## JSON Schema Definition

### v1.0 Schema (Excerpt)

```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.arusty.dev/mdbook-htmx/manifest/v1.0.schema.json",
"title": "mdbook-htmx Manifest Schema v1.0",
"type": "object",
"required": ["version", "pages"],
"properties": {
"$schema": {
"type": "string",
"format": "uri"
},
"version": {
"type": "string",
"pattern": "^1\\.[0-9]+\\.[0-9]+$",
"description": "Manifest schema version (semver)"
},
"generator": {
"$ref": "#/$defs/generator"
},
"generated": {
"type": "string",
"format": "date-time"
},
"book": {
"$ref": "#/$defs/book"
},
"pages": {
"type": "array",
"items": { "$ref": "#/$defs/page" }
},
"navigation": {
"$ref": "#/$defs/navigation"
}
},
"additionalProperties": true,
"$defs": {
"generator": {
"type": "object",
"properties": {
"name": { "type": "string" },
"version": { "type": "string" }
}
},
"page": {
"type": "object",
"required": ["path", "title"],
"properties": {
"path": { "type": "string" },
"title": { "type": "string" },
"file": { "type": "string" },
"fragment": { "type": "string" },
"auth": { "$ref": "#/$defs/auth" },
"scopes": {
"type": "array",
"items": { "type": "string" }
}
}
},
"auth": {
"type": "object",
"properties": {
"access": {
"enum": ["public", "authenticated", "roles"]
},
"roles": {
"type": "array",
"items": { "type": "string" }
},
"fallback": { "type": "string" }
}
}
}
}
```

## Consequences

### Positive

- Clear contract between generator and consumers
- Servers can validate manifests on startup
- Forward compatibility via `additionalProperties: true`
- Migration path for breaking changes
- Multiple server implementations can coexist

### Negative

- Version coordination between builds and deployments
- Must maintain schema versions
- Migration tooling adds complexity

### Mitigation

- Automated version checking in CI
- Schema published to CDN for validation
- Migration tooling provided by mdbook-htmx

## Alternatives Considered

### No Schema Versioning

Rely on duck typing and optional fields.

**Rejected** because:
- No way to know what fields to expect
- Breaking changes cause runtime errors
- Hard to document and test

### Hypermedia-Style Self-Description

Embed schema inline or via HTTP headers.

**Rejected** because:
- Increases manifest size significantly
- Unnecessary for file-based distribution
- $schema reference is sufficient

### GraphQL-Style Deprecation

Mark fields deprecated inline with `@deprecated`.

**Rejected** because:
- JSON doesn't support annotations
- Separate deprecation docs work better
- Keep manifest focused on runtime data

## References

- [JSON Schema $schema and $id](https://json-schema.org/understanding-json-schema/basics.html)
- [Semantic Versioning 2.0.0](https://semver.org/)
- [API Versioning Best Practices](https://www.postman.com/api-platform/api-versioning/)
- [ADR-0003: Authorization Metadata via Manifest](./0003-authorization-via-manifest.md)
- [ADR-0009: Audience Scopes](./0009-audience-scopes.md)
- [ADR-0014: Configuration Schema Versioning](./0014-configuration-schema-versioning.md)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.