content_assets + publication_bundles (draft only) * * Safety rules (enforced by this script, not by convention): * - Default mode is a read-only dry run: it re-runs the strict intake gate * on the chosen manifest and prints the staging plan. It writes nothing. * - --apply requires the gate to PASS for the chosen manifest, otherwise it * refuses. The full candidate manifest is therefore un promotable until * quarantined files are resolved by humans. * - --apply only creates `draft` bundles/assets with `review_required` / * `draft` review status. It never publishes, never approves rights, never * writes lessons.markdown_content, and never touches the live manifest.json. * - Files are staged content-addressed (sha256) so re-runs are idempotent. * - No exercise, lab, simulation, or question generation happens here. Those * require a published, reviewed bundle (see docs/GRADE10_CANDIDATE_INTAKE_GATE.md). * * Usage: * php backend/scripts/promote_grade10_candidate.php * php backend/scripts/promote_grade10_candidate.php --manifest=manifest.accepted.json * php backend/scripts/promote_grade10_candidate.php --manifest=manifest.accepted.json --apply */ $apply = in_array('--apply', $argv, true); $manifestName = 'manifest.accepted.json'; foreach ($argv as $arg) { if (str_starts_with($arg, '--manifest=')) { $manifestName = substr($arg, strlen('--manifest=')); } } $projectRoot = dirname(__DIR__, 2); $intakeRoot = $projectRoot . '/backend/storage/curriculum/_incoming'; $candidateRoot = $intakeRoot . '/grade_10'; $stageRoot = $projectRoot . '/backend/storage/curriculum/_staging/grade_10'; $manifestPath = $candidateRoot . '/' . basename($manifestName); $validator = $projectRoot . '/backend/scripts/validate_grade10_candidate.php'; // 1. Mandatory gate preflight: identical strict rules, no exceptions. $phpBinary = defined('PHP_BINARY') && PHP_BINARY !== '' ? PHP_BINARY : 'php'; $gateJson = shell_exec( escapeshellcmd($phpBinary) . ' ' . escapeshellarg($validator) . ' --json --manifest=' . escapeshellarg(basename($manifestName)) . ' 2>/dev/null' ); $gate = json_decode((string) $gateJson, true); if (!is_array($gate) || ($gate['status'] ?? null) !== 'passed') { fwrite(STDERR, "Promotion refused: intake gate did not pass for {$manifestName}.\n"); if (is_array($gate) && !empty($gate['errors'])) { fwrite(STDERR, sprintf("Gate reported %d blocking error(s):\n", count($gate['errors']))); foreach (array_slice($gate['errors'], 0, 10) as $err) { $contextFile = $err['context']['file'] ?? ($err['context']['path'] ?? ''); fwrite(STDERR, sprintf(" - [%s] %s%s\n", $err['code'] ?? 'error', $err['message'] ?? '', $contextFile ? " ({$contextFile})" : '')); } if (count($gate['errors']) > 10) { fwrite(STDERR, sprintf(" ... and %d more error(s). Run: php backend/scripts/validate_grade10_candidate.php --manifest=%s\n", count($gate['errors']) - 10, basename($manifestName))); } } elseif (empty($gateJson)) { fwrite(STDERR, "Preflight gate failed to execute or returned empty output.\n"); fwrite(STDERR, "Run the gate directly to see the exact issue:\n"); fwrite(STDERR, " php backend/scripts/validate_grade10_candidate.php --manifest=" . basename($manifestName) . "\n"); } fwrite(STDERR, "Resolve review-queue.json items first; quarantined files stay unpublished.\n"); exit(1); } $manifest = json_decode((string) file_get_contents($manifestPath), true); $entries = $manifest['lessons'] ?? []; $curriculumVersion = (string) ($manifest['curriculum_version'] ?? 'unknown'); $bundleVersion = 'grade10-intake-2026-09-10'; $plan = []; foreach ($entries as $entry) { $relativePath = (string) ($entry['file'] ?? ''); if (!preg_match('#\Agrade_10/[a-z0-9_]+/semester_[12]/unit_[0-9]+/[a-z0-9_]+\.md\z#', $relativePath)) { fwrite(STDERR, "Promotion refused: unsafe file path in manifest: {$relativePath}\n"); exit(1); } $abs = $intakeRoot . '/' . $relativePath; $sha = hash_file('sha256', $abs); $bytes = filesize($abs); if ($sha === false || $bytes === false) { fwrite(STDERR, "Promotion refused: unreadable file: {$relativePath}\n"); exit(1); } $frontmatter = parseFrontmatter((string) file_get_contents($abs)); $resourceType = (string) ($frontmatter['resource_type'] ?? ''); $source = $frontmatter['source'] ?? []; $pages = is_array($source['pages'] ?? null) ? $source['pages'] : []; $plan[] = [ 'file' => $relativePath, 'grade_key' => $entry['grade_key'], 'subject_key' => $entry['subject_key'], 'semester_key' => $entry['semester_key'], 'unit_key' => $entry['unit_key'], 'lesson_key' => $entry['lesson_key'], 'title' => $entry['title'], 'resource_type' => $resourceType, 'source_reference' => 'books/' . ($source['original_pdf'] ?? '?') . '#pages-' . implode(',', $pages), 'sha256' => $sha, 'byte_size' => $bytes, 'storage_key' => sprintf('staged/grade_10/%s/%s', substr($sha, 0, 12), $relativePath), 'asset_type' => match ($resourceType) { 'lesson' => 'lesson_markdown', 'worksheet' => 'worksheet_markdown', default => 'other', }, 'bundle_role' => match ($resourceType) { 'lesson' => 'primary_lesson', 'worksheet' => 'worksheet', default => 'supporting', }, ]; } $report = [ 'mode' => $apply ? 'apply' : 'dry_run', 'manifest' => basename($manifestName), 'curriculum_version' => $curriculumVersion, 'bundle_version' => $bundleVersion, 'bundle_status' => 'draft', 'asset_review_status' => 'draft', 'policy' => 'Draft staging only. No publication, no rights clearance, no lessons.markdown_content writes, no exercise/lab generation.', 'entries' => count($plan), ]; if (!$apply) { $report['plan'] = $plan; echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL; exit(0); } require_once dirname(__DIR__) . '/app/bootstrap.php'; use App\Core\Database; $pdo = Database::getConnection(); $pdo->beginTransaction(); try { foreach ($plan as $row) { $source = $intakeRoot . '/' . $row['file']; $destination = $projectRoot . '/backend/storage/curriculum/' . $row['storage_key']; if (!is_dir(dirname($destination)) && !mkdir(dirname($destination), 0750, true) && !is_dir(dirname($destination))) { throw new RuntimeException('Unable to create staging directory.'); } if (!copy($source, $destination) || !hash_equals($row['sha256'], hash_file('sha256', $destination))) { throw new RuntimeException('Staged copy checksum mismatch: ' . $row['file']); } Database::query( "INSERT INTO curriculum_lessons (uuid, grade_key, subject_key, semester_key, unit_key, lesson_key, curriculum_version, title, source_manifest_path, source_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'unverified') ON DUPLICATE KEY UPDATE title = VALUES(title), source_manifest_path = VALUES(source_manifest_path)", [selfUuid(), $row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key'], $curriculumVersion, $row['title'], $row['file']] ); $lessonId = (int) Database::selectOne( 'SELECT id FROM curriculum_lessons WHERE grade_key = ? AND subject_key = ? AND semester_key = ? AND unit_key = ? AND lesson_key = ? AND curriculum_version = ? LIMIT 1', [$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key'], $curriculumVersion] )['id']; Database::query( "INSERT INTO publication_bundles (uuid, curriculum_lesson_id, bundle_version, status) VALUES (?, ?, ?, 'draft') ON DUPLICATE KEY UPDATE id = id", [selfUuid(), $lessonId, $bundleVersion] ); $bundleId = (int) Database::selectOne( 'SELECT id FROM publication_bundles WHERE curriculum_lesson_id = ? AND bundle_version = ? LIMIT 1', [$lessonId, $bundleVersion] )['id']; Database::query( "INSERT INTO content_assets (uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, source_reference, rights_status, review_status) VALUES (?, ?, 'local', ?, 'text/markdown; charset=utf-8', ?, ?, ?, 'review_required', 'draft') ON DUPLICATE KEY UPDATE byte_size = VALUES(byte_size), source_reference = VALUES(source_reference)", [selfUuid(), $row['asset_type'], $row['storage_key'], $row['byte_size'], $row['sha256'], $row['source_reference']] ); $assetId = (int) Database::selectOne( 'SELECT id FROM content_assets WHERE storage_driver = ? AND storage_key = ? AND sha256 = ? LIMIT 1', ['local', $row['storage_key'], $row['sha256']] )['id']; Database::query( 'INSERT IGNORE INTO publication_bundle_assets (publication_bundle_id, content_asset_id, role) VALUES (?, ?, ?)', [$bundleId, $assetId, $row['bundle_role']] ); } $pdo->commit(); $report['staged_draft_entries'] = count($plan); echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL; } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } fwrite(STDERR, 'Promotion rolled back: ' . $e->getMessage() . "\n"); exit(1); } function parseFrontmatter(string $markdown): array { if (!preg_match('/\A---\r?\n(?.*?)\r?\n---\r?\n/s', $markdown, $match)) { return []; } $result = []; $section = null; foreach (preg_split('/\r?\n/', $match['yaml']) as $line) { if (preg_match('/^(\w+):\s*$/', $line, $sectionMatch)) { $section = $sectionMatch[1]; $result[$section] = []; continue; } if (preg_match('/^\s{2}(\w+):\s*(.+)$/', $line, $nestedMatch) && $section !== null) { $raw = trim($nestedMatch[2]); if (str_starts_with($raw, '[') && str_ends_with($raw, ']')) { $inner = trim(substr($raw, 1, -1)); $result[$section][$nestedMatch[1]] = $inner === '' ? [] : array_map(static fn (string $item): int|string => ctype_digit(trim($item)) ? (int) trim($item) : trim($item), explode(',', $inner)); } else { $result[$section][$nestedMatch[1]] = trim($raw, '"'); } continue; } if (preg_match('/^(\w+):\s*(.+)$/', $line, $scalarMatch)) { $section = null; $result[$scalarMatch[1]] = trim($scalarMatch[2], '"'); } } return $result; } function selfUuid(): string { $bytes = random_bytes(16); $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); }