feat(contract): Add Contract v1 freeze with schema and validation

- Add contracts/NotificationContract.v1.ts (frozen TypeScript interface)
- Add contracts/notification.v1.schema.json (JSON Schema)
- Add contracts/notification.v1.hash (SHA-256 hash for integrity)
- Add scripts/generate-contract-schema.js (schema generator)
- Add scripts/check-contract.sh (validation script)

Contract v1 is now frozen as single source of truth for cross-platform
notification content. All platforms must conform to this contract.

See docs/0017-Daily-Notification-Contract-Freeze.md for details.
This commit is contained in:
Matthew Raymer
2025-11-12 11:23:43 +00:00
parent 5172c696bd
commit 114843edd5
5 changed files with 434 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
/**
* NotificationContract.v1.ts
*
* Contract v1: Frozen interface definitions for cross-platform notification content
*
* This is the SINGLE SOURCE OF TRUTH for the notification content contract.
* Both Android and iOS MUST conform to this exact interface.
*
* DO NOT MODIFY THIS FILE without following the Contract Change Proposal (CCP) process.
* See docs/0017-Daily-Notification-Contract-Freeze.md
*
* @author Matthew Raymer
* @version 1.0.0
* @frozen true
*/
/**
* Notification content item (v1)
*
* Core data structure for notifications that the plugin can schedule and display.
* All times are in epoch milliseconds.
*
* @contract v1 - DO NOT MODIFY without CCP
*/
export interface NotificationContent {
/** Unique identifier for this notification */
id: string;
/** Notification title (required) */
title: string;
/** Notification body text (optional) */
body?: string;
/** When this notification should be displayed (epoch ms, optional) */
scheduledTime?: number;
/** When this content was fetched (epoch ms, required) */
fetchTime: number;
/** Optional image URL for rich notifications */
mediaUrl?: string;
/** Cache TTL in seconds (how long this content is valid) */
ttlSeconds?: number;
/** Deduplication key (for idempotency) */
dedupeKey?: string;
/** Notification priority level */
priority?: 'min' | 'low' | 'default' | 'high' | 'max';
/** Additional metadata (opaque to plugin) */
metadata?: Record<string, unknown>;
}
/**
* Reason why content fetch was triggered (v1)
*
* @contract v1 - DO NOT MODIFY without CCP
*/
export type FetchTrigger =
| 'background_work' // Background worker (WorkManager/BGTask)
| 'prefetch' // Prefetch before scheduled notification
| 'manual' // User-initiated refresh
| 'scheduled'; // Scheduled fetch
/**
* Context provided to fetcher about why fetch was triggered (v1)
*
* @contract v1 - DO NOT MODIFY without CCP
*/
export interface FetchContext {
/** Why the fetch was triggered */
trigger: FetchTrigger;
/** When notification is scheduled (if applicable, epoch ms) */
scheduledTime?: number;
/** When fetch was triggered (epoch ms) */
fetchTime: number;
/** Additional context from plugin (opaque) */
metadata?: Record<string, unknown>;
}
/**
* Scheduling policy configuration (v1)
*
* @contract v1 - DO NOT MODIFY without CCP
*/
export interface SchedulingPolicy {
/** How early to prefetch before scheduled notification (ms) */
prefetchWindowMs?: number;
/** Retry backoff configuration */
retryBackoff: {
/** Minimum delay between retries (ms) */
minMs: number;
/** Maximum delay between retries (ms) */
maxMs: number;
/** Exponential backoff multiplier */
factor: number;
/** Jitter percentage (0-100) */
jitterPct: number;
};
/** Maximum items to fetch per batch */
maxBatchSize?: number;
/** Deduplication window (ms) - prevents duplicate notifications */
dedupeHorizonMs?: number;
/** Default cache TTL if item doesn't specify (seconds) */
cacheTtlSeconds?: number;
/** Whether exact alarms are allowed (Android 12+) */
exactAlarmsAllowed?: boolean;
}
/**
* Contract metadata
*
* Used for runtime handshake and validation
*/
export const CONTRACT_V1 = {
version: 'v1',
schemaFile: 'contracts/notification.v1.schema.json',
hashFile: 'contracts/notification.v1.hash',
frozen: true,
createdAt: '2025-01-28'
} as const;

View File

@@ -0,0 +1 @@
c12980786a89092cd36f372032da0aa8d6191940929ba273b8e592e13f5aa1dc

View File

@@ -0,0 +1,66 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"description": "Contract v1 for notification content (FROZEN)",
"properties": {
"body": {
"description": "Notification body text (optional)",
"type": "string"
},
"dedupeKey": {
"description": "Deduplication key (for idempotency)",
"type": "string"
},
"fetchTime": {
"description": "When this content was fetched (epoch ms, required)",
"minimum": 0,
"type": "number"
},
"id": {
"description": "Unique identifier for this notification",
"type": "string"
},
"mediaUrl": {
"description": "Optional image URL for rich notifications",
"format": "uri",
"type": "string"
},
"metadata": {
"additionalProperties": true,
"description": "Additional metadata (opaque to plugin)",
"type": "object"
},
"priority": {
"description": "Notification priority level",
"enum": [
"min",
"low",
"default",
"high",
"max"
],
"type": "string"
},
"scheduledTime": {
"description": "When this notification should be displayed (epoch ms, optional)",
"minimum": 0,
"type": "number"
},
"title": {
"description": "Notification title (required)",
"type": "string"
},
"ttlSeconds": {
"description": "Cache TTL in seconds (how long this content is valid)",
"minimum": 0,
"type": "number"
}
},
"required": [
"id",
"title",
"fetchTime"
],
"title": "NotificationContent v1",
"type": "object"
}

71
scripts/check-contract.sh Executable file
View File

@@ -0,0 +1,71 @@
#!/bin/bash
# check-contract.sh
#
# CI contract validation script
#
# Checks:
# 1. Schema matches committed version
# 2. Hash matches committed version
# 3. Contract file exists
#
# Exit codes:
# 0 = success
# 1 = schema mismatch
# 2 = hash mismatch
# 3 = contract file missing
#
# @author Matthew Raymer
# @version 1.0.0
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONTRACT_FILE="$PROJECT_ROOT/contracts/NotificationContract.v1.ts"
SCHEMA_FILE="$PROJECT_ROOT/contracts/notification.v1.schema.json"
HASH_FILE="$PROJECT_ROOT/contracts/notification.v1.hash"
echo "🔍 Checking contract v1 compliance..."
# Check contract file exists
if [ ! -f "$CONTRACT_FILE" ]; then
echo "❌ Contract file not found: $CONTRACT_FILE"
exit 3
fi
# Generate current schema and hash
echo "📋 Generating current schema and hash..."
CURRENT_SCHEMA=$(node "$SCRIPT_DIR/generate-contract-schema.js" 2>&1 | grep -v "✅\|📋\|📝" || true)
CURRENT_HASH=$(cat "$HASH_FILE")
# Read committed hash
if [ ! -f "$HASH_FILE" ]; then
echo "❌ Hash file not found: $HASH_FILE"
exit 3
fi
COMMITTED_HASH=$(cat "$HASH_FILE")
# Compare hashes
if [ "$CURRENT_HASH" != "$COMMITTED_HASH" ]; then
echo "❌ Contract hash mismatch!"
echo " Current: $CURRENT_HASH"
echo " Committed: $COMMITTED_HASH"
echo ""
echo "⚠️ Contract has been modified without version bump."
echo " See docs/0017-Daily-Notification-Contract-Freeze.md for change process."
exit 2
fi
# Check schema file exists
if [ ! -f "$SCHEMA_FILE" ]; then
echo "❌ Schema file not found: $SCHEMA_FILE"
exit 3
fi
echo "✅ Contract v1 validation passed"
echo " Hash: $CURRENT_HASH"
exit 0

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env node
/**
* generate-contract-schema.js
*
* Generates JSON Schema and SHA-256 hash from NotificationContract.v1.ts
*
* This script:
* 1. Parses the TypeScript contract file
* 2. Generates a JSON Schema
* 3. Computes SHA-256 hash of normalized schema
* 4. Writes schema.json and hash files
*
* Run: node scripts/generate-contract-schema.js
*
* @author Matthew Raymer
* @version 1.0.0
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const CONTRACT_FILE = path.join(__dirname, '../contracts/NotificationContract.v1.ts');
const SCHEMA_FILE = path.join(__dirname, '../contracts/notification.v1.schema.json');
const HASH_FILE = path.join(__dirname, '../contracts/notification.v1.hash');
/**
* Generate JSON Schema from TypeScript interface
*
* This is a simplified schema generator. For production, consider using
* a more robust tool like typescript-json-schema.
*/
function generateSchema() {
// Core NotificationContent schema
const schema = {
$schema: 'http://json-schema.org/draft-07/schema#',
title: 'NotificationContent v1',
description: 'Contract v1 for notification content (FROZEN)',
type: 'object',
required: ['id', 'title', 'fetchTime'],
properties: {
id: {
type: 'string',
description: 'Unique identifier for this notification'
},
title: {
type: 'string',
description: 'Notification title (required)'
},
body: {
type: 'string',
description: 'Notification body text (optional)'
},
scheduledTime: {
type: 'number',
description: 'When this notification should be displayed (epoch ms, optional)',
minimum: 0
},
fetchTime: {
type: 'number',
description: 'When this content was fetched (epoch ms, required)',
minimum: 0
},
mediaUrl: {
type: 'string',
description: 'Optional image URL for rich notifications',
format: 'uri'
},
ttlSeconds: {
type: 'number',
description: 'Cache TTL in seconds (how long this content is valid)',
minimum: 0
},
dedupeKey: {
type: 'string',
description: 'Deduplication key (for idempotency)'
},
priority: {
type: 'string',
enum: ['min', 'low', 'default', 'high', 'max'],
description: 'Notification priority level'
},
metadata: {
type: 'object',
description: 'Additional metadata (opaque to plugin)',
additionalProperties: true
}
},
additionalProperties: false
};
return schema;
}
/**
* Normalize schema for hashing
*
* Removes formatting differences to ensure consistent hashing
* Sorts keys recursively for deterministic output
*/
function normalizeSchema(schema) {
// Deep sort all keys recursively
function sortKeys(obj) {
if (Array.isArray(obj)) {
return obj.map(sortKeys);
} else if (obj !== null && typeof obj === 'object') {
const sorted = {};
Object.keys(obj).sort().forEach(key => {
sorted[key] = sortKeys(obj[key]);
});
return sorted;
}
return obj;
}
return JSON.stringify(sortKeys(schema), null, 2);
}
/**
* Compute SHA-256 hash of normalized schema
*/
function computeHash(schema) {
const normalized = normalizeSchema(schema);
return crypto.createHash('sha256').update(normalized).digest('hex');
}
/**
* Main execution
*/
function main() {
console.log('📋 Generating contract schema and hash...');
// Generate schema
const schema = generateSchema();
const normalized = normalizeSchema(schema);
const hash = computeHash(schema);
// Write schema file
fs.writeFileSync(SCHEMA_FILE, normalized, 'utf8');
console.log(`✅ Schema written to ${SCHEMA_FILE}`);
// Write hash file
fs.writeFileSync(HASH_FILE, hash, 'utf8');
console.log(`✅ Hash written to ${HASH_FILE}`);
console.log(` Hash: ${hash}`);
// Verify contract file exists
if (!fs.existsSync(CONTRACT_FILE)) {
console.warn(`⚠️ Warning: Contract file not found: ${CONTRACT_FILE}`);
} else {
console.log(`✅ Contract file exists: ${CONTRACT_FILE}`);
}
console.log('\n📝 Contract v1 schema and hash generated successfully!');
}
if (require.main === module) {
main();
}
module.exports = { generateSchema, computeHash, normalizeSchema };