diff --git a/repository-component-owner-approval-guard/README.md b/repository-component-owner-approval-guard/README.md
new file mode 100644
index 00000000..154239f0
--- /dev/null
+++ b/repository-component-owner-approval-guard/README.md
@@ -0,0 +1,34 @@
+# Repository Component Owner Approval Guard
+
+Self-contained guard for SCIBASE issue #10. It validates whether merge requests and tagged scientific repository releases have current approval coverage from the required component owners before protected-branch merge.
+
+The guard focuses on component-owner approval quorum, not merge queue execution, repository release generation, provenance attestation, access review, DOI tombstones, sensitive-artifact scanning, dependency licensing, branch hypothesis lineage, or legal holds.
+
+## What It Checks
+
+- Component ownership for `manuscript/`, `data/`, `code/`, `notebooks/`, `protocols/`, `results/`, and `metadata.json`
+- Required owner roles per changed component
+- Escalation owners for restricted data or protocol changes
+- Stale approvals after the latest file change
+- Conflicted self-approvals by merge request authors
+- Unmapped repository paths without owner policy coverage
+
+## Commands
+
+```bash
+npm run check
+npm test
+npm run demo
+npm run demo:video
+```
+
+## Reviewer Artifacts
+
+`npm run demo` and `npm run demo:video` generate:
+
+- `reports/summary.json`
+- `reports/reviewer-packet.md`
+- `reports/summary.svg`
+- `reports/demo.webm`
+
+All data is synthetic and local. The module does not call Git providers, repository hosting APIs, identity systems, storage systems, or external services.
diff --git a/repository-component-owner-approval-guard/demo-video.js b/repository-component-owner-approval-guard/demo-video.js
new file mode 100644
index 00000000..1f4b7f72
--- /dev/null
+++ b/repository-component-owner-approval-guard/demo-video.js
@@ -0,0 +1,186 @@
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+const { execFileSync } = require("child_process");
+const { componentPolicy, mergeRequests } = require("./sample-data");
+const { evaluateRepositoryChanges } = require("./index");
+
+const result = evaluateRepositoryChanges({
+ mergeRequests,
+ policy: componentPolicy
+});
+
+const reportDir = path.join(__dirname, "reports");
+const outputPath = path.join(reportDir, "demo.webm");
+
+const browserCandidates = [
+ process.env.CHROME_PATH,
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
+ "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
+].filter(Boolean);
+
+function findBrowser() {
+ const found = browserCandidates.find((candidate) => fs.existsSync(candidate));
+ if (!found) {
+ throw new Error("Chrome or Edge was not found. Set CHROME_PATH to generate reports/demo.webm.");
+ }
+ return found;
+}
+
+function fileUrl(filePath) {
+ return `file:///${filePath.replace(/\\/g, "/")}`;
+}
+
+const html = String.raw`
+
+
+
+ Repository component owner approval guard demo
+
+
+
+
+ recording
+
+
+`;
+
+fs.mkdirSync(reportDir, { recursive: true });
+
+const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "repository-owner-demo-"));
+const htmlPath = path.join(tempDir, "demo.html");
+const profileDir = path.join(tempDir, "profile");
+fs.writeFileSync(htmlPath, html, "utf8");
+
+const stdout = execFileSync(
+ findBrowser(),
+ [
+ "--headless=new",
+ "--disable-gpu",
+ "--disable-dev-shm-usage",
+ "--autoplay-policy=no-user-gesture-required",
+ "--run-all-compositor-stages-before-draw",
+ "--virtual-time-budget=7500",
+ `--user-data-dir=${profileDir}`,
+ "--dump-dom",
+ fileUrl(htmlPath)
+ ],
+ { encoding: "utf8", maxBuffer: 30 * 1024 * 1024 }
+);
+
+const match = stdout.match(/data:video\/webm;base64,([A-Za-z0-9+/=]+)/);
+if (!match) {
+ throw new Error(`Demo video generation failed. Browser output ended with: ${stdout.slice(-600)}`);
+}
+
+fs.writeFileSync(outputPath, Buffer.from(match[1], "base64"));
+console.log(`Generated ${path.relative(process.cwd(), outputPath)}`);
\ No newline at end of file
diff --git a/repository-component-owner-approval-guard/demo.js b/repository-component-owner-approval-guard/demo.js
new file mode 100644
index 00000000..c11e80cc
--- /dev/null
+++ b/repository-component-owner-approval-guard/demo.js
@@ -0,0 +1,12 @@
+const path = require("path");
+const { componentPolicy, mergeRequests } = require("./sample-data");
+const { evaluateRepositoryChanges, writeReports } = require("./index");
+
+const result = evaluateRepositoryChanges({
+ mergeRequests,
+ policy: componentPolicy
+});
+
+writeReports(result, path.join(__dirname, "reports"));
+
+console.log(JSON.stringify(result.summary, null, 2));
diff --git a/repository-component-owner-approval-guard/index.js b/repository-component-owner-approval-guard/index.js
new file mode 100644
index 00000000..958d405d
--- /dev/null
+++ b/repository-component-owner-approval-guard/index.js
@@ -0,0 +1,338 @@
+const fs = require("fs");
+const path = require("path");
+
+function normalizePath(filePath) {
+ return String(filePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
+}
+
+function matchComponent(filePath, policy) {
+ const normalized = normalizePath(filePath);
+ return Object.entries(policy).find(([, rule]) =>
+ rule.paths.some((prefix) => {
+ const normalizedPrefix = normalizePath(prefix);
+ return normalized === normalizedPrefix || normalized.startsWith(normalizedPrefix);
+ })
+ );
+}
+
+function groupChangedComponents(files, policy) {
+ const groups = new Map();
+
+ for (const file of files) {
+ const match = matchComponent(file.path, policy);
+ const component = match ? match[0] : "unmapped";
+ if (!groups.has(component)) {
+ groups.set(component, []);
+ }
+ groups.get(component).push({
+ ...file,
+ path: normalizePath(file.path)
+ });
+ }
+
+ return groups;
+}
+
+function approvalIsFresh(approval, changedAt) {
+ return new Date(approval.approvedAt).getTime() >= new Date(changedAt).getTime();
+}
+
+function approvalsForComponent(approvals, component) {
+ return approvals.filter((approval) => approval.components.includes(component));
+}
+
+function requiredRolesForComponent(component, files, policy) {
+ const rule = policy[component];
+ if (!rule) {
+ return ["repository-curator"];
+ }
+
+ const roles = new Set(rule.owners);
+ if (files.some((file) => file.restricted) && rule.restrictedOwners) {
+ for (const owner of rule.restrictedOwners) {
+ roles.add(owner);
+ }
+ }
+
+ return [...roles];
+}
+
+function requiredEscalationRolesForComponent(component, files, policy) {
+ const rule = policy[component];
+ if (!rule || !rule.restrictedOwners) {
+ return [];
+ }
+
+ if (!files.some((file) => file.restricted)) {
+ return [];
+ }
+
+ return rule.restrictedOwners;
+}
+
+function evaluateComponent({ component, files, request, policy }) {
+ const rule = policy[component] || { quorum: 1 };
+ const requiredRoles = requiredRolesForComponent(component, files, policy);
+ const requiredEscalationRoles = requiredEscalationRolesForComponent(component, files, policy);
+ const scopedApprovals = approvalsForComponent(request.approvals, component);
+ const freshApprovals = scopedApprovals.filter((approval) =>
+ approvalIsFresh(approval, request.changedAt)
+ );
+ const eligibleApprovals = freshApprovals.filter((approval) =>
+ requiredRoles.includes(approval.role) && !request.authors.includes(approval.user)
+ );
+
+ const findings = [];
+ const approvedRoles = new Set(eligibleApprovals.map((approval) => approval.role));
+ const missingEscalationRoles = requiredEscalationRoles.filter((role) => !approvedRoles.has(role));
+ const staleApprovals = scopedApprovals.filter(
+ (approval) => !approvalIsFresh(approval, request.changedAt)
+ );
+ const selfApprovals = scopedApprovals.filter((approval) =>
+ request.authors.includes(approval.user)
+ );
+ const outOfScopeApprovals = scopedApprovals.filter(
+ (approval) => !requiredRoles.includes(approval.role)
+ );
+
+ if (component === "unmapped") {
+ findings.push({
+ severity: "critical",
+ code: "unmapped-component-change",
+ message: "Changed files do not map to an owned repository component.",
+ files: files.map((file) => file.path)
+ });
+ }
+
+ if (eligibleApprovals.length < rule.quorum) {
+ findings.push({
+ severity: "critical",
+ code: "approval-quorum-missing",
+ message: `${component} requires ${rule.quorum} eligible owner approval(s).`,
+ requiredRoles,
+ eligibleRoles: [...approvedRoles]
+ });
+ }
+
+ if (missingEscalationRoles.length > 0) {
+ findings.push({
+ severity: "critical",
+ code: "required-owner-role-missing",
+ message: `${component} is missing restricted-change escalation owner approval(s).`,
+ missingRoles: missingEscalationRoles
+ });
+ }
+
+ if (staleApprovals.length > 0) {
+ findings.push({
+ severity: "critical",
+ code: "stale-approval-after-change",
+ message: `${component} has approval(s) older than the latest changed file timestamp.`,
+ approvals: staleApprovals.map((approval) => ({
+ user: approval.user,
+ role: approval.role,
+ approvedAt: approval.approvedAt
+ }))
+ });
+ }
+
+ if (selfApprovals.length > 0) {
+ findings.push({
+ severity: "critical",
+ code: "conflicted-self-approval",
+ message: `${component} includes approval from a merge request author.`,
+ approvals: selfApprovals.map((approval) => ({
+ user: approval.user,
+ role: approval.role
+ }))
+ });
+ }
+
+ if (outOfScopeApprovals.length > 0) {
+ findings.push({
+ severity: "warning",
+ code: "owner-role-out-of-scope",
+ message: `${component} includes approval role(s) that do not satisfy this component policy.`,
+ approvals: outOfScopeApprovals.map((approval) => ({
+ user: approval.user,
+ role: approval.role
+ }))
+ });
+ }
+
+ return {
+ component,
+ files: files.map((file) => file.path),
+ restricted: files.some((file) => file.restricted),
+ requiredRoles,
+ eligibleApprovals: eligibleApprovals.map((approval) => ({
+ user: approval.user,
+ role: approval.role,
+ approvedAt: approval.approvedAt
+ })),
+ findings
+ };
+}
+
+function decisionForFindings(findings) {
+ if (findings.some((finding) => finding.severity === "critical")) {
+ return "block-merge";
+ }
+ if (findings.some((finding) => finding.severity === "warning")) {
+ return "require-owner-review";
+ }
+ return "approve-merge";
+}
+
+function scoreFindings(findings) {
+ const penalties = findings.reduce((total, finding) => {
+ if (finding.severity === "critical") {
+ return total + 30;
+ }
+ if (finding.severity === "warning") {
+ return total + 12;
+ }
+ return total + 4;
+ }, 0);
+ return Math.max(0, 100 - penalties);
+}
+
+function evaluateMergeRequest(request, policy) {
+ const groups = groupChangedComponents(request.files, policy);
+ const components = [...groups.entries()].map(([component, files]) =>
+ evaluateComponent({ component, files, request, policy })
+ );
+ const findings = components.flatMap((component) => component.findings);
+
+ return {
+ id: request.id,
+ title: request.title,
+ authors: request.authors,
+ changedAt: request.changedAt,
+ touchedComponents: components.map((component) => component.component),
+ decision: decisionForFindings(findings),
+ score: scoreFindings(findings),
+ components,
+ findings
+ };
+}
+
+function summarizeEvaluations(evaluations) {
+ const decisions = evaluations.reduce((summary, evaluation) => {
+ summary[evaluation.decision] = (summary[evaluation.decision] || 0) + 1;
+ return summary;
+ }, {});
+
+ const findingCounts = evaluations
+ .flatMap((evaluation) => evaluation.findings)
+ .reduce((counts, finding) => {
+ counts[finding.code] = (counts[finding.code] || 0) + 1;
+ return counts;
+ }, {});
+
+ return {
+ totalMergeRequests: evaluations.length,
+ decisions,
+ findingCounts,
+ blocked: evaluations
+ .filter((evaluation) => evaluation.decision === "block-merge")
+ .map((evaluation) => evaluation.id),
+ approved: evaluations
+ .filter((evaluation) => evaluation.decision === "approve-merge")
+ .map((evaluation) => evaluation.id)
+ };
+}
+
+function evaluateRepositoryChanges({ mergeRequests, policy }) {
+ const evaluations = mergeRequests.map((request) => evaluateMergeRequest(request, policy));
+ return {
+ generatedAt: new Date("2026-05-22T19:30:00Z").toISOString(),
+ guard: "repository-component-owner-approval-guard",
+ summary: summarizeEvaluations(evaluations),
+ evaluations
+ };
+}
+
+function renderMarkdownReport(result) {
+ const lines = [
+ "# Repository Component Owner Approval Guard",
+ "",
+ `Generated: ${result.generatedAt}`,
+ "",
+ "## Summary",
+ "",
+ `- Total merge requests: ${result.summary.totalMergeRequests}`,
+ `- Approved: ${result.summary.decisions["approve-merge"] || 0}`,
+ `- Require owner review: ${result.summary.decisions["require-owner-review"] || 0}`,
+ `- Blocked: ${result.summary.decisions["block-merge"] || 0}`,
+ "",
+ "## Merge Request Decisions",
+ ""
+ ];
+
+ for (const evaluation of result.evaluations) {
+ lines.push(`### ${evaluation.id}: ${evaluation.title}`);
+ lines.push("");
+ lines.push(`- Decision: ${evaluation.decision}`);
+ lines.push(`- Score: ${evaluation.score}`);
+ lines.push(`- Components: ${evaluation.touchedComponents.join(", ")}`);
+
+ if (evaluation.findings.length === 0) {
+ lines.push("- Findings: none");
+ } else {
+ lines.push("- Findings:");
+ for (const finding of evaluation.findings) {
+ lines.push(` - ${finding.severity}: ${finding.code} - ${finding.message}`);
+ }
+ }
+ lines.push("");
+ }
+
+ return `${lines.join("\n").trimEnd()}\n`;
+}
+
+function renderSvgSummary(result) {
+ const approved = result.summary.decisions["approve-merge"] || 0;
+ const review = result.summary.decisions["require-owner-review"] || 0;
+ const blocked = result.summary.decisions["block-merge"] || 0;
+
+ return [
+ '"
+ ].join("");
+}
+
+function metricBlock(x, y, label, value, color) {
+ return [
+ ``,
+ `${label}`,
+ `${value}`
+ ].join("");
+}
+
+function writeReports(result, outputDir) {
+ fs.mkdirSync(outputDir, { recursive: true });
+ fs.writeFileSync(path.join(outputDir, "summary.json"), `${JSON.stringify(result, null, 2)}\n`);
+ fs.writeFileSync(path.join(outputDir, "reviewer-packet.md"), renderMarkdownReport(result));
+ fs.writeFileSync(path.join(outputDir, "summary.svg"), renderSvgSummary(result));
+}
+
+module.exports = {
+ evaluateMergeRequest,
+ evaluateRepositoryChanges,
+ groupChangedComponents,
+ matchComponent,
+ normalizePath,
+ renderMarkdownReport,
+ renderSvgSummary,
+ requiredRolesForComponent,
+ writeReports
+};
diff --git a/repository-component-owner-approval-guard/package.json b/repository-component-owner-approval-guard/package.json
new file mode 100644
index 00000000..673d771c
--- /dev/null
+++ b/repository-component-owner-approval-guard/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "repository-component-owner-approval-guard",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Component-owner approval quorum guard for scientific project repository changes.",
+ "main": "index.js",
+ "scripts": {
+ "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && node --check demo-video.js",
+ "test": "node test.js",
+ "demo": "node demo.js",
+ "demo:video": "node demo-video.js"
+ },
+ "license": "MIT"
+}
diff --git a/repository-component-owner-approval-guard/reports/demo.webm b/repository-component-owner-approval-guard/reports/demo.webm
new file mode 100644
index 00000000..bcf2b5f7
Binary files /dev/null and b/repository-component-owner-approval-guard/reports/demo.webm differ
diff --git a/repository-component-owner-approval-guard/reports/reviewer-packet.md b/repository-component-owner-approval-guard/reports/reviewer-packet.md
new file mode 100644
index 00000000..88e5ef56
--- /dev/null
+++ b/repository-component-owner-approval-guard/reports/reviewer-packet.md
@@ -0,0 +1,48 @@
+# Repository Component Owner Approval Guard
+
+Generated: 2026-05-22T19:30:00.000Z
+
+## Summary
+
+- Total merge requests: 4
+- Approved: 2
+- Require owner review: 0
+- Blocked: 2
+
+## Merge Request Decisions
+
+### MR-2401: Refresh methods text and analysis notebook
+
+- Decision: approve-merge
+- Score: 100
+- Components: manuscript, notebooks, results
+- Findings: none
+
+### MR-2417: Publish restricted participant table with updated protocol
+
+- Decision: block-merge
+- Score: 0
+- Components: data, protocols, metadata
+- Findings:
+ - critical: required-owner-role-missing - data is missing restricted-change escalation owner approval(s).
+ - critical: approval-quorum-missing - protocols requires 1 eligible owner approval(s).
+ - critical: required-owner-role-missing - protocols is missing restricted-change escalation owner approval(s).
+ - critical: conflicted-self-approval - protocols includes approval from a merge request author.
+
+### MR-2422: Retag code package after model output changes
+
+- Decision: block-merge
+- Score: 0
+- Components: code, results, metadata
+- Findings:
+ - critical: approval-quorum-missing - code requires 1 eligible owner approval(s).
+ - critical: stale-approval-after-change - code has approval(s) older than the latest changed file timestamp.
+ - critical: approval-quorum-missing - results requires 1 eligible owner approval(s).
+ - critical: stale-approval-after-change - results has approval(s) older than the latest changed file timestamp.
+
+### MR-2430: Release curated dataset and citation metadata
+
+- Decision: approve-merge
+- Score: 100
+- Components: data, metadata, manuscript
+- Findings: none
diff --git a/repository-component-owner-approval-guard/reports/summary.json b/repository-component-owner-approval-guard/reports/summary.json
new file mode 100644
index 00000000..69c9c302
--- /dev/null
+++ b/repository-component-owner-approval-guard/reports/summary.json
@@ -0,0 +1,490 @@
+{
+ "generatedAt": "2026-05-22T19:30:00.000Z",
+ "guard": "repository-component-owner-approval-guard",
+ "summary": {
+ "totalMergeRequests": 4,
+ "decisions": {
+ "approve-merge": 2,
+ "block-merge": 2
+ },
+ "findingCounts": {
+ "required-owner-role-missing": 2,
+ "approval-quorum-missing": 3,
+ "conflicted-self-approval": 1,
+ "stale-approval-after-change": 2
+ },
+ "blocked": [
+ "MR-2417",
+ "MR-2422"
+ ],
+ "approved": [
+ "MR-2401",
+ "MR-2430"
+ ]
+ },
+ "evaluations": [
+ {
+ "id": "MR-2401",
+ "title": "Refresh methods text and analysis notebook",
+ "authors": [
+ "lee"
+ ],
+ "changedAt": "2026-05-22T09:20:00Z",
+ "touchedComponents": [
+ "manuscript",
+ "notebooks",
+ "results"
+ ],
+ "decision": "approve-merge",
+ "score": 100,
+ "components": [
+ {
+ "component": "manuscript",
+ "files": [
+ "manuscript/methods.md"
+ ],
+ "restricted": false,
+ "requiredRoles": [
+ "scientific-editor",
+ "corresponding-author"
+ ],
+ "eligibleApprovals": [
+ {
+ "user": "mira",
+ "role": "scientific-editor",
+ "approvedAt": "2026-05-22T09:52:00Z"
+ }
+ ],
+ "findings": []
+ },
+ {
+ "component": "notebooks",
+ "files": [
+ "notebooks/analysis.ipynb"
+ ],
+ "restricted": false,
+ "requiredRoles": [
+ "analysis-lead",
+ "reproducibility-engineer"
+ ],
+ "eligibleApprovals": [
+ {
+ "user": "arun",
+ "role": "analysis-lead",
+ "approvedAt": "2026-05-22T10:04:00Z"
+ }
+ ],
+ "findings": []
+ },
+ {
+ "component": "results",
+ "files": [
+ "results/figure-2.svg"
+ ],
+ "restricted": false,
+ "requiredRoles": [
+ "analysis-lead",
+ "scientific-editor"
+ ],
+ "eligibleApprovals": [
+ {
+ "user": "mira",
+ "role": "scientific-editor",
+ "approvedAt": "2026-05-22T09:52:00Z"
+ },
+ {
+ "user": "arun",
+ "role": "analysis-lead",
+ "approvedAt": "2026-05-22T10:04:00Z"
+ }
+ ],
+ "findings": []
+ }
+ ],
+ "findings": []
+ },
+ {
+ "id": "MR-2417",
+ "title": "Publish restricted participant table with updated protocol",
+ "authors": [
+ "tess"
+ ],
+ "changedAt": "2026-05-22T11:45:00Z",
+ "touchedComponents": [
+ "data",
+ "protocols",
+ "metadata"
+ ],
+ "decision": "block-merge",
+ "score": 0,
+ "components": [
+ {
+ "component": "data",
+ "files": [
+ "data/participant_export.csv"
+ ],
+ "restricted": true,
+ "requiredRoles": [
+ "data-steward",
+ "privacy-reviewer",
+ "irb-liaison"
+ ],
+ "eligibleApprovals": [
+ {
+ "user": "noor",
+ "role": "data-steward",
+ "approvedAt": "2026-05-22T12:01:00Z"
+ }
+ ],
+ "findings": [
+ {
+ "severity": "critical",
+ "code": "required-owner-role-missing",
+ "message": "data is missing restricted-change escalation owner approval(s).",
+ "missingRoles": [
+ "privacy-reviewer",
+ "irb-liaison"
+ ]
+ }
+ ]
+ },
+ {
+ "component": "protocols",
+ "files": [
+ "protocols/collection-plan.md"
+ ],
+ "restricted": true,
+ "requiredRoles": [
+ "protocol-owner",
+ "lab-manager",
+ "irb-liaison"
+ ],
+ "eligibleApprovals": [],
+ "findings": [
+ {
+ "severity": "critical",
+ "code": "approval-quorum-missing",
+ "message": "protocols requires 1 eligible owner approval(s).",
+ "requiredRoles": [
+ "protocol-owner",
+ "lab-manager",
+ "irb-liaison"
+ ],
+ "eligibleRoles": []
+ },
+ {
+ "severity": "critical",
+ "code": "required-owner-role-missing",
+ "message": "protocols is missing restricted-change escalation owner approval(s).",
+ "missingRoles": [
+ "protocol-owner",
+ "irb-liaison"
+ ]
+ },
+ {
+ "severity": "critical",
+ "code": "conflicted-self-approval",
+ "message": "protocols includes approval from a merge request author.",
+ "approvals": [
+ {
+ "user": "tess",
+ "role": "protocol-owner"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "component": "metadata",
+ "files": [
+ "metadata.json"
+ ],
+ "restricted": false,
+ "requiredRoles": [
+ "repository-curator",
+ "corresponding-author"
+ ],
+ "eligibleApprovals": [
+ {
+ "user": "ivy",
+ "role": "repository-curator",
+ "approvedAt": "2026-05-22T12:08:00Z"
+ }
+ ],
+ "findings": []
+ }
+ ],
+ "findings": [
+ {
+ "severity": "critical",
+ "code": "required-owner-role-missing",
+ "message": "data is missing restricted-change escalation owner approval(s).",
+ "missingRoles": [
+ "privacy-reviewer",
+ "irb-liaison"
+ ]
+ },
+ {
+ "severity": "critical",
+ "code": "approval-quorum-missing",
+ "message": "protocols requires 1 eligible owner approval(s).",
+ "requiredRoles": [
+ "protocol-owner",
+ "lab-manager",
+ "irb-liaison"
+ ],
+ "eligibleRoles": []
+ },
+ {
+ "severity": "critical",
+ "code": "required-owner-role-missing",
+ "message": "protocols is missing restricted-change escalation owner approval(s).",
+ "missingRoles": [
+ "protocol-owner",
+ "irb-liaison"
+ ]
+ },
+ {
+ "severity": "critical",
+ "code": "conflicted-self-approval",
+ "message": "protocols includes approval from a merge request author.",
+ "approvals": [
+ {
+ "user": "tess",
+ "role": "protocol-owner"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "MR-2422",
+ "title": "Retag code package after model output changes",
+ "authors": [
+ "omar"
+ ],
+ "changedAt": "2026-05-22T15:35:00Z",
+ "touchedComponents": [
+ "code",
+ "results",
+ "metadata"
+ ],
+ "decision": "block-merge",
+ "score": 0,
+ "components": [
+ {
+ "component": "code",
+ "files": [
+ "code/model/train.py"
+ ],
+ "restricted": false,
+ "requiredRoles": [
+ "analysis-lead",
+ "reproducibility-engineer"
+ ],
+ "eligibleApprovals": [],
+ "findings": [
+ {
+ "severity": "critical",
+ "code": "approval-quorum-missing",
+ "message": "code requires 1 eligible owner approval(s).",
+ "requiredRoles": [
+ "analysis-lead",
+ "reproducibility-engineer"
+ ],
+ "eligibleRoles": []
+ },
+ {
+ "severity": "critical",
+ "code": "stale-approval-after-change",
+ "message": "code has approval(s) older than the latest changed file timestamp.",
+ "approvals": [
+ {
+ "user": "arun",
+ "role": "analysis-lead",
+ "approvedAt": "2026-05-22T14:58:00Z"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "component": "results",
+ "files": [
+ "results/model-card.md"
+ ],
+ "restricted": false,
+ "requiredRoles": [
+ "analysis-lead",
+ "scientific-editor"
+ ],
+ "eligibleApprovals": [],
+ "findings": [
+ {
+ "severity": "critical",
+ "code": "approval-quorum-missing",
+ "message": "results requires 1 eligible owner approval(s).",
+ "requiredRoles": [
+ "analysis-lead",
+ "scientific-editor"
+ ],
+ "eligibleRoles": []
+ },
+ {
+ "severity": "critical",
+ "code": "stale-approval-after-change",
+ "message": "results has approval(s) older than the latest changed file timestamp.",
+ "approvals": [
+ {
+ "user": "arun",
+ "role": "analysis-lead",
+ "approvedAt": "2026-05-22T14:58:00Z"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "component": "metadata",
+ "files": [
+ "metadata.json"
+ ],
+ "restricted": false,
+ "requiredRoles": [
+ "repository-curator",
+ "corresponding-author"
+ ],
+ "eligibleApprovals": [
+ {
+ "user": "ivy",
+ "role": "repository-curator",
+ "approvedAt": "2026-05-22T16:00:00Z"
+ }
+ ],
+ "findings": []
+ }
+ ],
+ "findings": [
+ {
+ "severity": "critical",
+ "code": "approval-quorum-missing",
+ "message": "code requires 1 eligible owner approval(s).",
+ "requiredRoles": [
+ "analysis-lead",
+ "reproducibility-engineer"
+ ],
+ "eligibleRoles": []
+ },
+ {
+ "severity": "critical",
+ "code": "stale-approval-after-change",
+ "message": "code has approval(s) older than the latest changed file timestamp.",
+ "approvals": [
+ {
+ "user": "arun",
+ "role": "analysis-lead",
+ "approvedAt": "2026-05-22T14:58:00Z"
+ }
+ ]
+ },
+ {
+ "severity": "critical",
+ "code": "approval-quorum-missing",
+ "message": "results requires 1 eligible owner approval(s).",
+ "requiredRoles": [
+ "analysis-lead",
+ "scientific-editor"
+ ],
+ "eligibleRoles": []
+ },
+ {
+ "severity": "critical",
+ "code": "stale-approval-after-change",
+ "message": "results has approval(s) older than the latest changed file timestamp.",
+ "approvals": [
+ {
+ "user": "arun",
+ "role": "analysis-lead",
+ "approvedAt": "2026-05-22T14:58:00Z"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "MR-2430",
+ "title": "Release curated dataset and citation metadata",
+ "authors": [
+ "nina"
+ ],
+ "changedAt": "2026-05-22T17:00:00Z",
+ "touchedComponents": [
+ "data",
+ "metadata",
+ "manuscript"
+ ],
+ "decision": "approve-merge",
+ "score": 100,
+ "components": [
+ {
+ "component": "data",
+ "files": [
+ "data/curated-measurements.parquet"
+ ],
+ "restricted": false,
+ "requiredRoles": [
+ "data-steward",
+ "privacy-reviewer"
+ ],
+ "eligibleApprovals": [
+ {
+ "user": "noor",
+ "role": "data-steward",
+ "approvedAt": "2026-05-22T17:10:00Z"
+ }
+ ],
+ "findings": []
+ },
+ {
+ "component": "metadata",
+ "files": [
+ "metadata.json"
+ ],
+ "restricted": false,
+ "requiredRoles": [
+ "repository-curator",
+ "corresponding-author"
+ ],
+ "eligibleApprovals": [
+ {
+ "user": "ivy",
+ "role": "repository-curator",
+ "approvedAt": "2026-05-22T17:15:00Z"
+ }
+ ],
+ "findings": []
+ },
+ {
+ "component": "manuscript",
+ "files": [
+ "manuscript/data-availability.md"
+ ],
+ "restricted": false,
+ "requiredRoles": [
+ "scientific-editor",
+ "corresponding-author"
+ ],
+ "eligibleApprovals": [
+ {
+ "user": "mira",
+ "role": "scientific-editor",
+ "approvedAt": "2026-05-22T17:18:00Z"
+ }
+ ],
+ "findings": []
+ }
+ ],
+ "findings": []
+ }
+ ]
+}
diff --git a/repository-component-owner-approval-guard/reports/summary.svg b/repository-component-owner-approval-guard/reports/summary.svg
new file mode 100644
index 00000000..e8482cbf
--- /dev/null
+++ b/repository-component-owner-approval-guard/reports/summary.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/repository-component-owner-approval-guard/requirements-map.md b/repository-component-owner-approval-guard/requirements-map.md
new file mode 100644
index 00000000..14e05f31
--- /dev/null
+++ b/repository-component-owner-approval-guard/requirements-map.md
@@ -0,0 +1,15 @@
+# Requirements Map
+
+## Issue #10 Requirement Coverage
+
+- Repository structure and components: maps component owners across `manuscript/`, `data/`, `code/`, `notebooks/`, `protocols/`, `results/`, and `metadata.json`.
+- File and metadata versioning: blocks stale approvals when changed files move after review.
+- Collaboration and merge requests: evaluates merge request approval quorum before protected-branch merge.
+- Provenance tracking: records which owner roles approved each component and why a merge is blocked.
+- In-browser editors and diffs: treats changed paths as editor/diff outputs that need owner coverage.
+- Computation-aware reproducibility: checks code, notebook, and result owners for reproducibility-sensitive changes.
+- Repository identifiers and citation: requires metadata owner approval for `metadata.json` citation/version changes.
+
+## Non-Overlap Statement
+
+This slice is limited to component-owner approval quorum and owner freshness. It does not implement the existing repository ledger, release engine, structured diff/rollback, provenance attestation, release embargo, notebook replay, schema migration, citation impact, API/export verifier, merge queue, environment drift, access review, DOI tombstone, metadata readiness, branch hypothesis lineage, sensitive-artifact, dependency-license, or legal-hold slices.
diff --git a/repository-component-owner-approval-guard/sample-data.js b/repository-component-owner-approval-guard/sample-data.js
new file mode 100644
index 00000000..b022570b
--- /dev/null
+++ b/repository-component-owner-approval-guard/sample-data.js
@@ -0,0 +1,159 @@
+const componentPolicy = {
+ manuscript: {
+ paths: ["manuscript/"],
+ owners: ["scientific-editor", "corresponding-author"],
+ quorum: 1
+ },
+ data: {
+ paths: ["data/"],
+ owners: ["data-steward", "privacy-reviewer"],
+ quorum: 1,
+ restrictedOwners: ["privacy-reviewer", "irb-liaison"]
+ },
+ code: {
+ paths: ["code/"],
+ owners: ["analysis-lead", "reproducibility-engineer"],
+ quorum: 1
+ },
+ notebooks: {
+ paths: ["notebooks/"],
+ owners: ["analysis-lead", "reproducibility-engineer"],
+ quorum: 1
+ },
+ protocols: {
+ paths: ["protocols/"],
+ owners: ["protocol-owner", "lab-manager"],
+ quorum: 1,
+ restrictedOwners: ["protocol-owner", "irb-liaison"]
+ },
+ results: {
+ paths: ["results/"],
+ owners: ["analysis-lead", "scientific-editor"],
+ quorum: 1
+ },
+ metadata: {
+ paths: ["metadata.json"],
+ owners: ["repository-curator", "corresponding-author"],
+ quorum: 1
+ }
+};
+
+const mergeRequests = [
+ {
+ id: "MR-2401",
+ title: "Refresh methods text and analysis notebook",
+ authors: ["lee"],
+ changedAt: "2026-05-22T09:20:00Z",
+ files: [
+ { path: "manuscript/methods.md", changeType: "modify" },
+ { path: "notebooks/analysis.ipynb", changeType: "modify" },
+ { path: "results/figure-2.svg", changeType: "add" }
+ ],
+ approvals: [
+ {
+ user: "mira",
+ role: "scientific-editor",
+ components: ["manuscript", "results"],
+ approvedAt: "2026-05-22T09:52:00Z"
+ },
+ {
+ user: "arun",
+ role: "analysis-lead",
+ components: ["notebooks", "results"],
+ approvedAt: "2026-05-22T10:04:00Z"
+ }
+ ]
+ },
+ {
+ id: "MR-2417",
+ title: "Publish restricted participant table with updated protocol",
+ authors: ["tess"],
+ changedAt: "2026-05-22T11:45:00Z",
+ files: [
+ { path: "data/participant_export.csv", changeType: "add", restricted: true },
+ { path: "protocols/collection-plan.md", changeType: "modify", restricted: true },
+ { path: "metadata.json", changeType: "modify" }
+ ],
+ approvals: [
+ {
+ user: "noor",
+ role: "data-steward",
+ components: ["data"],
+ approvedAt: "2026-05-22T12:01:00Z"
+ },
+ {
+ user: "tess",
+ role: "protocol-owner",
+ components: ["protocols"],
+ approvedAt: "2026-05-22T12:04:00Z"
+ },
+ {
+ user: "ivy",
+ role: "repository-curator",
+ components: ["metadata"],
+ approvedAt: "2026-05-22T12:08:00Z"
+ }
+ ]
+ },
+ {
+ id: "MR-2422",
+ title: "Retag code package after model output changes",
+ authors: ["omar"],
+ changedAt: "2026-05-22T15:35:00Z",
+ files: [
+ { path: "code/model/train.py", changeType: "modify" },
+ { path: "results/model-card.md", changeType: "modify" },
+ { path: "metadata.json", changeType: "modify" }
+ ],
+ approvals: [
+ {
+ user: "arun",
+ role: "analysis-lead",
+ components: ["code", "results"],
+ approvedAt: "2026-05-22T14:58:00Z"
+ },
+ {
+ user: "ivy",
+ role: "repository-curator",
+ components: ["metadata"],
+ approvedAt: "2026-05-22T16:00:00Z"
+ }
+ ]
+ },
+ {
+ id: "MR-2430",
+ title: "Release curated dataset and citation metadata",
+ authors: ["nina"],
+ changedAt: "2026-05-22T17:00:00Z",
+ files: [
+ { path: "data/curated-measurements.parquet", changeType: "add" },
+ { path: "metadata.json", changeType: "modify" },
+ { path: "manuscript/data-availability.md", changeType: "modify" }
+ ],
+ approvals: [
+ {
+ user: "noor",
+ role: "data-steward",
+ components: ["data"],
+ approvedAt: "2026-05-22T17:10:00Z"
+ },
+ {
+ user: "ivy",
+ role: "repository-curator",
+ components: ["metadata"],
+ approvedAt: "2026-05-22T17:15:00Z"
+ },
+ {
+ user: "mira",
+ role: "scientific-editor",
+ components: ["manuscript"],
+ approvedAt: "2026-05-22T17:18:00Z"
+ }
+ ]
+ }
+];
+
+module.exports = {
+ componentPolicy,
+ mergeRequests
+};
diff --git a/repository-component-owner-approval-guard/test.js b/repository-component-owner-approval-guard/test.js
new file mode 100644
index 00000000..4ec5a7b0
--- /dev/null
+++ b/repository-component-owner-approval-guard/test.js
@@ -0,0 +1,50 @@
+const assert = require("assert");
+const { componentPolicy, mergeRequests } = require("./sample-data");
+const {
+ evaluateMergeRequest,
+ evaluateRepositoryChanges,
+ matchComponent,
+ requiredRolesForComponent
+} = require("./index");
+
+function codes(evaluation) {
+ return evaluation.findings.map((finding) => finding.code);
+}
+
+assert.strictEqual(matchComponent("metadata.json", componentPolicy)[0], "metadata");
+assert.strictEqual(matchComponent("notebooks/run.ipynb", componentPolicy)[0], "notebooks");
+
+assert.deepStrictEqual(
+ requiredRolesForComponent(
+ "data",
+ [{ path: "data/raw.csv", restricted: true }],
+ componentPolicy
+ ),
+ ["data-steward", "privacy-reviewer", "irb-liaison"]
+);
+
+const clean = evaluateMergeRequest(mergeRequests[0], componentPolicy);
+assert.strictEqual(clean.decision, "approve-merge");
+assert.strictEqual(clean.findings.length, 0);
+
+const restricted = evaluateMergeRequest(mergeRequests[1], componentPolicy);
+assert.strictEqual(restricted.decision, "block-merge");
+assert.ok(codes(restricted).includes("required-owner-role-missing"));
+assert.ok(codes(restricted).includes("conflicted-self-approval"));
+
+const stale = evaluateMergeRequest(mergeRequests[2], componentPolicy);
+assert.strictEqual(stale.decision, "block-merge");
+assert.ok(codes(stale).includes("stale-approval-after-change"));
+
+const release = evaluateMergeRequest(mergeRequests[3], componentPolicy);
+assert.strictEqual(release.decision, "approve-merge");
+
+const result = evaluateRepositoryChanges({
+ mergeRequests,
+ policy: componentPolicy
+});
+assert.strictEqual(result.summary.totalMergeRequests, 4);
+assert.deepStrictEqual(result.summary.blocked.sort(), ["MR-2417", "MR-2422"]);
+assert.deepStrictEqual(result.summary.approved.sort(), ["MR-2401", "MR-2430"]);
+
+console.log("repository-component-owner-approval-guard tests passed");