Files
saqel/backend/scripts/promote_grade10_candidate.php

239 lines
11 KiB
PHP

<?php
declare(strict_types=1);
/**
* Safe promotion path for the Grade 10 candidate intake bundle.
*
* _incoming/grade_10 ---> 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(?<yaml>.*?)\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));
}