From 114843edd5f48975b571b292b808be1ea5169ab8 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Wed, 12 Nov 2025 11:23:43 +0000 Subject: [PATCH] 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. --- contracts/NotificationContract.v1.ts | 133 +++++++++++++++++++++ contracts/notification.v1.hash | 1 + contracts/notification.v1.schema.json | 66 +++++++++++ scripts/check-contract.sh | 71 +++++++++++ scripts/generate-contract-schema.js | 163 ++++++++++++++++++++++++++ 5 files changed, 434 insertions(+) create mode 100644 contracts/NotificationContract.v1.ts create mode 100644 contracts/notification.v1.hash create mode 100644 contracts/notification.v1.schema.json create mode 100755 scripts/check-contract.sh create mode 100644 scripts/generate-contract-schema.js diff --git a/contracts/NotificationContract.v1.ts b/contracts/NotificationContract.v1.ts new file mode 100644 index 0000000..e8d7095 --- /dev/null +++ b/contracts/NotificationContract.v1.ts @@ -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; +} + +/** + * 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; +} + +/** + * 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; + diff --git a/contracts/notification.v1.hash b/contracts/notification.v1.hash new file mode 100644 index 0000000..8758f9f --- /dev/null +++ b/contracts/notification.v1.hash @@ -0,0 +1 @@ +c12980786a89092cd36f372032da0aa8d6191940929ba273b8e592e13f5aa1dc \ No newline at end of file diff --git a/contracts/notification.v1.schema.json b/contracts/notification.v1.schema.json new file mode 100644 index 0000000..4c9e248 --- /dev/null +++ b/contracts/notification.v1.schema.json @@ -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" +} \ No newline at end of file diff --git a/scripts/check-contract.sh b/scripts/check-contract.sh new file mode 100755 index 0000000..19a81c0 --- /dev/null +++ b/scripts/check-contract.sh @@ -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 + diff --git a/scripts/generate-contract-schema.js b/scripts/generate-contract-schema.js new file mode 100644 index 0000000..b36e590 --- /dev/null +++ b/scripts/generate-contract-schema.js @@ -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 }; +